From 65ae2b6a35cc51558a208fe7cce33e90ef25b89a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 15:01:19 -0500 Subject: [PATCH 01/70] Update session limit doc strings and add `hard_limit_eviction` --- crates/config/src/sections/experimental.rs | 27 ++++++++++++++++++++++ docs/config.schema.json | 9 +++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index b8f3920b0..ae98b6a70 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -121,6 +121,33 @@ impl ConfigurationSection for ExperimentalConfig { /// Configuration options for the session limit feature #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SessionLimitConfig { + /// Upon login in interactive contexts, if the soft limit is reached, it will + /// display a policy violation screen (web UI) to remove sessions before creating + /// the new session. + /// + /// This is not enforced in uninteractive contexts (like the legacy compability + /// login API). See [`hard_limit`] for enforcement on that side. pub soft_limit: NonZeroU64, + /// Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy + /// violation error), otherwise, see [`hard_limit_eviction`]. + /// + /// The hard limit is enforced in all contexts (interactive/uninteractive). pub hard_limit: NonZeroU64, + /// Whether we should automatically choose the least recently used devices to remove + /// when the [`hard_limit`] is reached; in order to allow the new login to continue. + /// + /// WARNING: Removing sessions is a potentially damaging operation. Any end-to-end + /// encrypted history on the device will be lost and can only be recovered if you + /// have another verified active device or have a recovery key setup. + /// + /// When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to + /// avoid catastropically losing encrypted history and digital identity. + /// + /// This is most applicable in scenarios where your homeserver has many legacy + /// bots/scripts that login over and over (which ideally should be using [personal + /// access + /// tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492)) + /// and you want to avoid breaking their operation while maintaining some level of + /// sanity with the number of devices that people can have. + pub hard_limit_eviction: bool, } diff --git a/docs/config.schema.json b/docs/config.schema.json index f6d947e48..ff52b1fef 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2898,19 +2898,26 @@ "type": "object", "properties": { "soft_limit": { + "description": "Upon login in interactive contexts, if the soft limit is reached, it will\n display a policy violation screen (web UI) to remove sessions before creating\n the new session.\n\n This is not enforced in uninteractive contexts (like the legacy compability\n login API). See [`hard_limit`] for enforcement on that side.", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit": { + "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/uninteractive).", "type": "integer", "format": "uint64", "minimum": 1 + }, + "hard_limit_eviction": { + "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", + "type": "boolean" } }, "required": [ "soft_limit", - "hard_limit" + "hard_limit", + "hard_limit_eviction" ] } } From 5462277f6364fa3ffe100f69e624f3405523d639 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 15:40:12 -0500 Subject: [PATCH 02/70] Validation (take 1) --- crates/config/src/sections/experimental.rs | 48 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index ae98b6a70..79c37999e 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -8,7 +8,7 @@ use std::num::NonZeroU64; use chrono::Duration; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::Error as _}; use serde_with::serde_as; use crate::ConfigurationSection; @@ -17,6 +17,10 @@ fn default_true() -> bool { true } +fn default_false() -> bool { + false +} + fn default_token_ttl() -> Duration { Duration::microseconds(5 * 60 * 1000 * 1000) } @@ -116,6 +120,16 @@ impl ExperimentalConfig { impl ConfigurationSection for ExperimentalConfig { const PATH: Option<&'static str> = Some("experimental"); + + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { + if let Some(session_limit) = &self.session_limit { + session_limit.validate(figment)?; + } + Ok(()) + } } /// Configuration options for the session limit feature @@ -141,7 +155,8 @@ pub struct SessionLimitConfig { /// have another verified active device or have a recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to - /// avoid catastropically losing encrypted history and digital identity. + /// avoid catastropically losing encrypted history and digital identity in + /// pathological cases. /// /// This is most applicable in scenarios where your homeserver has many legacy /// bots/scripts that login over and over (which ideally should be using [personal @@ -149,5 +164,34 @@ pub struct SessionLimitConfig { /// tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492)) /// and you want to avoid breaking their operation while maintaining some level of /// sanity with the number of devices that people can have. + #[serde(default = "default_false")] pub hard_limit_eviction: bool, } + +impl ConfigurationSection for SessionLimitConfig { + const PATH: Option<&'static str> = Some("session_limit"); + + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { + let metadata = figment.find_metadata(Self::PATH.unwrap()); + let annotate = |mut error: figment::Error| { + error.metadata = metadata.cloned(); + error.profile = Some(figment::Profile::Default); + error.path = vec![Self::PATH.unwrap().to_owned()]; + error + }; + + // See [`hard_limit_eviction`] docstring + if self.hard_limit_eviction && self.hard_limit.get() < 2 { + return Err(figment::error::Error::custom( + "Session hard limit must be at-least 2 when automatic `hard_limit_eviction` is set.", + ) + .with_path("experimental.session_limit.hard_limit") + .into()); + } + + Ok(()) + } +} From 0927f68d768b9673d572a34e344894db89eb5170 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 16:01:17 -0500 Subject: [PATCH 03/70] More refined figment error --- crates/config/src/sections/experimental.rs | 14 ++++++-------- docs/config.schema.json | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 79c37999e..1d1a76498 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -8,7 +8,7 @@ use std::num::NonZeroU64; use chrono::Duration; use schemars::JsonSchema; -use serde::{Deserialize, Serialize, de::Error as _}; +use serde::{Deserialize, Serialize}; use serde_with::serde_as; use crate::ConfigurationSection; @@ -176,20 +176,18 @@ impl ConfigurationSection for SessionLimitConfig { figment: &figment::Figment, ) -> Result<(), Box> { let metadata = figment.find_metadata(Self::PATH.unwrap()); - let annotate = |mut error: figment::Error| { + let error_on_field = |mut error: figment::error::Error, field: &'static str| { error.metadata = metadata.cloned(); error.profile = Some(figment::Profile::Default); - error.path = vec![Self::PATH.unwrap().to_owned()]; + error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()]; error }; // See [`hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { - return Err(figment::error::Error::custom( - "Session hard limit must be at-least 2 when automatic `hard_limit_eviction` is set.", - ) - .with_path("experimental.session_limit.hard_limit") - .into()); + return Err(error_on_field(figment::error::Error::from( + "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set.", + ), "hard_limit").into()); } Ok(()) diff --git a/docs/config.schema.json b/docs/config.schema.json index ff52b1fef..9c70e056f 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,14 +2910,14 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", - "type": "boolean" + "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity in\n pathological cases.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", + "type": "boolean", + "default": false } }, "required": [ "soft_limit", - "hard_limit", - "hard_limit_eviction" + "hard_limit" ] } } From e4c1be96fcac6ed5464c87f2cecde955c4f58b95 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 16:03:49 -0500 Subject: [PATCH 04/70] Disabled by default --- crates/config/src/sections/experimental.rs | 5 ++++- docs/config.schema.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 1d1a76498..c55cd8823 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -150,13 +150,16 @@ pub struct SessionLimitConfig { /// Whether we should automatically choose the least recently used devices to remove /// when the [`hard_limit`] is reached; in order to allow the new login to continue. /// + /// Disabled by default + /// /// WARNING: Removing sessions is a potentially damaging operation. Any end-to-end /// encrypted history on the device will be lost and can only be recovered if you /// have another verified active device or have a recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to /// avoid catastropically losing encrypted history and digital identity in - /// pathological cases. + /// pathological cases. Keep in mind this is a bare minimum restriction and you can + /// still run into trouble. /// /// This is most applicable in scenarios where your homeserver has many legacy /// bots/scripts that login over and over (which ideally should be using [personal diff --git a/docs/config.schema.json b/docs/config.schema.json index 9c70e056f..34dc725f9 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,7 +2910,7 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity in\n pathological cases.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", + "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity in\n pathological cases. Keep in mind this is a bare minimum restriction and you can\n still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", "type": "boolean", "default": false } From 5532c0cda9a2e1d1944366dd75020c8eb53ebc81 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 17:42:09 -0500 Subject: [PATCH 05/70] Better rustdoc links --- Cargo.lock | 1 + crates/cli/src/util.rs | 2 ++ crates/config/src/sections/experimental.rs | 19 +++++++++++++------ crates/config/src/sections/mod.rs | 2 +- crates/data-model/Cargo.toml | 3 ++- crates/data-model/src/site_config.rs | 2 ++ docs/config.schema.json | 6 +++--- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f78a22e6d..10aa06460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3223,6 +3223,7 @@ dependencies = [ "base64ct", "chrono", "crc", + "mas-config", "mas-iana", "mas-jose", "oauth2-types", diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 454276150..caa76664d 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -156,6 +156,7 @@ pub async fn policy_factory_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, + hard_limit_eviction: c.hard_limit_eviction, }); let data = mas_policy::Data::new(matrix_config.homeserver.clone(), session_limit_config) @@ -242,6 +243,7 @@ pub fn site_config_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, + hard_limit_eviction: c.hard_limit_eviction, }), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index c55cd8823..0f99d9e9a 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -141,14 +141,18 @@ pub struct SessionLimitConfig { /// /// This is not enforced in uninteractive contexts (like the legacy compability /// login API). See [`hard_limit`] for enforcement on that side. + /// + /// [`hard_limit`]: Self::hard_limit pub soft_limit: NonZeroU64, /// Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy /// violation error), otherwise, see [`hard_limit_eviction`]. /// /// The hard limit is enforced in all contexts (interactive/uninteractive). + /// + /// [`hard_limit_eviction`]: Self::hard_limit_eviction pub hard_limit: NonZeroU64, /// Whether we should automatically choose the least recently used devices to remove - /// when the [`hard_limit`] is reached; in order to allow the new login to continue. + /// when the [`Self::hard_limit`] is reached; in order to allow the new login to continue. /// /// Disabled by default /// @@ -156,10 +160,10 @@ pub struct SessionLimitConfig { /// encrypted history on the device will be lost and can only be recovered if you /// have another verified active device or have a recovery key setup. /// - /// When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to - /// avoid catastropically losing encrypted history and digital identity in - /// pathological cases. Keep in mind this is a bare minimum restriction and you can - /// still run into trouble. + /// When using [`hard_limit_eviction`], the [`hard_limit`] must be + /// at-least 2 to avoid catastropically losing encrypted history and digital + /// identity in pathological cases. Keep in mind this is a bare minimum restriction + /// and you can still run into trouble. /// /// This is most applicable in scenarios where your homeserver has many legacy /// bots/scripts that login over and over (which ideally should be using [personal @@ -167,6 +171,9 @@ pub struct SessionLimitConfig { /// tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492)) /// and you want to avoid breaking their operation while maintaining some level of /// sanity with the number of devices that people can have. + /// + /// [`hard_limit`]: Self::hard_limit + /// [`hard_limit_eviction`]: Self::hard_limit_eviction #[serde(default = "default_false")] pub hard_limit_eviction: bool, } @@ -186,7 +193,7 @@ impl ConfigurationSection for SessionLimitConfig { error }; - // See [`hard_limit_eviction`] docstring + // See [`SessionLimitConfig::hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { return Err(error_on_field(figment::error::Error::from( "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set.", diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index eb4ff2a44..ae805c230 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -34,7 +34,7 @@ pub use self::{ clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, database::{DatabaseConfig, PgSslMode}, email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, - experimental::ExperimentalConfig, + experimental::{ExperimentalConfig, SessionLimitConfig as ExperimentalSessionLimitConfig}, http::{ BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig, Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp, diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index 7e9be8282..a1ff799bd 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -31,6 +31,7 @@ regex.workspace = true woothee.workspace = true mas-iana.workspace = true +# Added for rustdoc links +mas-config.workspace = true mas-jose.workspace = true oauth2-types.workspace = true - diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index bb92dc3e4..c27cbeb05 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -39,10 +39,12 @@ pub struct SessionExpirationConfig { pub compat_session_inactivity_ttl: Option, } +/// See [`mas_config::sections::ExperimentalSessionLimitConfig`] #[derive(Serialize, Debug, Clone)] pub struct SessionLimitConfig { pub soft_limit: NonZeroU64, pub hard_limit: NonZeroU64, + pub hard_limit_eviction: bool, } /// Random site configuration we want accessible in various places. diff --git a/docs/config.schema.json b/docs/config.schema.json index 34dc725f9..cfd93717a 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2898,19 +2898,19 @@ "type": "object", "properties": { "soft_limit": { - "description": "Upon login in interactive contexts, if the soft limit is reached, it will\n display a policy violation screen (web UI) to remove sessions before creating\n the new session.\n\n This is not enforced in uninteractive contexts (like the legacy compability\n login API). See [`hard_limit`] for enforcement on that side.", + "description": "Upon login in interactive contexts, if the soft limit is reached, it will\n display a policy violation screen (web UI) to remove sessions before creating\n the new session.\n\n This is not enforced in uninteractive contexts (like the legacy compability\n login API). See [`hard_limit`] for enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit": { - "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/uninteractive).", + "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/uninteractive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`hard_limit`] is reached; in order to allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be at-least 2 to\n avoid catastropically losing encrypted history and digital identity in\n pathological cases. Keep in mind this is a bare minimum restriction and you can\n still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.", + "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`Self::hard_limit`] is reached; in order to allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastropically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum restriction\n and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } From ae0e08db144f3a23562b15c848366599e1452211 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 17:54:29 -0500 Subject: [PATCH 06/70] Thread through some `site_config.session_limit` --- crates/handlers/src/compat/login.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5e57ce5c0..5332e6abe 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -12,8 +12,8 @@ use chrono::Duration; use hyper::StatusCode; use mas_axum_utils::record_error; use mas_data_model::{ - BoxClock, BoxRng, Clock, CompatSession, CompatSsoLoginState, Device, SiteConfig, TokenType, - User, + BoxClock, BoxRng, Clock, CompatSession, CompatSsoLoginState, Device, SessionLimitConfig, + SiteConfig, TokenType, User, }; use mas_matrix::HomeserverConnection; use mas_policy::{Policy, Requester, ViolationVariant, model::CompatLogin}; @@ -359,6 +359,7 @@ pub(crate) async fn post( ip_address: activity_tracker.ip(), user_agent: user_agent.clone(), }, + &site_config.session_limit, username, password, input.device_id, // TODO check for validity @@ -606,6 +607,8 @@ async fn token_login( if res.violations.len() == 1 { let violation = &res.violations[0]; if violation.variant == Some(ViolationVariant::TooManySessions) { + // TODO + // The only violation is having reached the session limit. return Err(RouteError::PolicyHardSessionLimitReached); } @@ -645,6 +648,7 @@ async fn user_password_login( repo: &mut BoxRepository, policy: &mut Policy, policy_requester: Requester, + session_limit_config: &Option, username: &str, password: String, requested_device_id: Option, @@ -739,8 +743,16 @@ async fn user_password_login( if res.violations.len() == 1 { let violation = &res.violations[0]; if violation.variant == Some(ViolationVariant::TooManySessions) { - // The only violation is having reached the session limit. - return Err(RouteError::PolicyHardSessionLimitReached); + let session_limit_config = session_limit_config.as_ref() + .expect("We should have a session_limit config if we are seeing a `TooManySessions` violation. \ + This is most likely a programming error."); + + if session_limit_config.hard_limit_eviction { + // TODO + } else { + // The only violation is having reached the session limit. + return Err(RouteError::PolicyHardSessionLimitReached); + } } } return Err(RouteError::PolicyRejected); From d407efcbe7785b0639d9debe17b5fc947abca21c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 3 Apr 2026 17:55:31 -0500 Subject: [PATCH 07/70] Fix `warning: it is more idiomatic to use `Option<&T>` instead of `&Option`` --- crates/handlers/src/compat/login.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5332e6abe..9eaceecff 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -359,7 +359,7 @@ pub(crate) async fn post( ip_address: activity_tracker.ip(), user_agent: user_agent.clone(), }, - &site_config.session_limit, + site_config.session_limit.as_ref(), username, password, input.device_id, // TODO check for validity @@ -648,7 +648,7 @@ async fn user_password_login( repo: &mut BoxRepository, policy: &mut Policy, policy_requester: Requester, - session_limit_config: &Option, + session_limit_config: Option<&SessionLimitConfig>, username: &str, password: String, requested_device_id: Option, From 8964793f317a101eeeaff45948f00d9611c2b8c9 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 14:50:53 -0500 Subject: [PATCH 08/70] Draft: Evict old devices --- crates/config/src/sections/experimental.rs | 16 +- crates/handlers/src/compat/login.rs | 82 +- docs/config.schema.json | 4 +- frontend/schema.graphql | 2491 -------------------- 4 files changed, 78 insertions(+), 2515 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 0f99d9e9a..0b3691ce5 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -135,19 +135,20 @@ impl ConfigurationSection for ExperimentalConfig { /// Configuration options for the session limit feature #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SessionLimitConfig { - /// Upon login in interactive contexts, if the soft limit is reached, it will - /// display a policy violation screen (web UI) to remove sessions before creating - /// the new session. + /// Upon login in interactive contexts (like OAuth 2.0 sessions), if the soft limit + /// is reached, it will display a policy violation screen (web UI) to remove + /// sessions before creating the new session. /// - /// This is not enforced in uninteractive contexts (like the legacy compability - /// login API). See [`hard_limit`] for enforcement on that side. + /// This is not enforced in non-interactive contexts (like the legacy compability + /// login API) as there is no opportunity for us to show some UI for people remove + /// some sessions. See [`hard_limit`] for enforcement on that side. /// /// [`hard_limit`]: Self::hard_limit pub soft_limit: NonZeroU64, /// Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy /// violation error), otherwise, see [`hard_limit_eviction`]. /// - /// The hard limit is enforced in all contexts (interactive/uninteractive). + /// The hard limit is enforced in all contexts (interactive/non-interactive). /// /// [`hard_limit_eviction`]: Self::hard_limit_eviction pub hard_limit: NonZeroU64, @@ -196,7 +197,8 @@ impl ConfigurationSection for SessionLimitConfig { // See [`SessionLimitConfig::hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { return Err(error_on_field(figment::error::Error::from( - "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set.", + "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set. \ + See configuration docs for more info.", ), "hard_limit").into()); } diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 9eaceecff..92f35914e 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -18,10 +18,10 @@ use mas_data_model::{ use mas_matrix::HomeserverConnection; use mas_policy::{Policy, Requester, ViolationVariant, model::CompatLogin}; use mas_storage::{ - BoxRepository, BoxRepositoryFactory, RepositoryAccess, + BoxRepository, BoxRepositoryFactory, Pagination, RepositoryAccess, compat::{ - CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository, - CompatSsoLoginRepository, + CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionFilter, + CompatSessionRepository, CompatSsoLoginRepository, }, queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, user::{UserPasswordRepository, UserRepository}, @@ -735,27 +735,79 @@ async fn user_password_login( }) .await?; if !res.valid() { - // If the only violation is that we have too many sessions, then handle that - // separately. - // In the future, we intend to evict some sessions automatically instead. We - // don't trigger this if there was some other violation anyway, since that means - // that removing a session wouldn't actually unblock the login. - if res.violations.len() == 1 { - let violation = &res.violations[0]; - if violation.variant == Some(ViolationVariant::TooManySessions) { + match (res.violations.len(), res.violations.first()) { + // If the only violation is having reached the session limit, we might be + // able to resolve the situation. + // + // We don't trigger this if there was some other violation anyway, since + // that means that removing a session wouldn't actually unblock the login. + (1, Some(violation)) + if violation.variant == Some(ViolationVariant::TooManySessions) => + { let session_limit_config = session_limit_config.as_ref() - .expect("We should have a session_limit config if we are seeing a `TooManySessions` violation. \ + .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ This is most likely a programming error."); + // TODO: This should come from `ViolationVariant::TooManySessions` + let need_to_remove: u32 = 1; + + // When logging in with the compatibility API, there is no way for us to + // display any web UI for people to remove devices, so we instead + // automatically remove their oldest devices (when `hard_limit_eviction` + // is configured). if session_limit_config.hard_limit_eviction { - // TODO + // For now, we only automatically clean up compatibility sessions. + // If there aren't enough sessions that we could clean up, we just + // throw an error with an explanation. + if session_counts.compat < need_to_remove.into() { + return Err(RouteError::PolicyHardSessionLimitReached); + } + + // Find the least recently used compat sessions + let compat = repo + .compat_session() + .list( + // TODO: Order by `last_active_at` + CompatSessionFilter::new().for_user(&user).active_only(), + Pagination::first( + usize::try_from(need_to_remove) + .map_err(|err| RouteError::Internal(err.into()))?, + ), + ) + .await?; + + // Remove the sessions + let sessions_removed = { + let mut sessions_removed = 0; + for edge in compat.edges { + let (compat_session, _) = edge.node; + let compat_session = + repo.compat_session().finish(clock, compat_session).await?; + sessions_removed += 1; + } + sessions_removed + }; + + // For now, we only automatically clean up compatibility sessions. + // If there are still too many sessions, we just throw an error with + // an explanation. + // + // In the future, it may be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys uploaded + // for device?). + if sessions_removed < need_to_remove { + return Err(RouteError::PolicyHardSessionLimitReached); + } } else { - // The only violation is having reached the session limit. + // Tell the user about the limit return Err(RouteError::PolicyHardSessionLimitReached); } } + // Just throw an error for any other violation + _ => { + return Err(RouteError::PolicyRejected); + } } - return Err(RouteError::PolicyRejected); } let session = repo diff --git a/docs/config.schema.json b/docs/config.schema.json index cfd93717a..72b85a7e3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2898,13 +2898,13 @@ "type": "object", "properties": { "soft_limit": { - "description": "Upon login in interactive contexts, if the soft limit is reached, it will\n display a policy violation screen (web UI) to remove sessions before creating\n the new session.\n\n This is not enforced in uninteractive contexts (like the legacy compability\n login API). See [`hard_limit`] for enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", + "description": "Upon login in interactive contexts (like OAuth 2.0 sessions), if the soft limit\n is reached, it will display a policy violation screen (web UI) to remove\n sessions before creating the new session.\n\n This is not enforced in non-interactive contexts (like the legacy compability\n login API) as there is no opportunity for us to show some UI for people remove\n some sessions. See [`hard_limit`] for enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit": { - "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/uninteractive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/non-interactive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "integer", "format": "uint64", "minimum": 1 diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 99da32010..e69de29bb 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1,2491 +0,0 @@ -""" -The input for the `addEmail` mutation -""" -input AddEmailInput { - """ - The email address to add - """ - email: String! - """ - The ID of the user to add the email address to - """ - userId: ID! - """ - Skip the email address verification. Only allowed for admins. - """ - skipVerification: Boolean - """ - Skip the email address policy check. Only allowed for admins. - """ - skipPolicyCheck: Boolean -} - -""" -The payload of the `addEmail` mutation -""" -type AddEmailPayload { - """ - Status of the operation - """ - status: AddEmailStatus! - """ - The email address that was added - """ - email: UserEmail - """ - The user to whom the email address was added - """ - user: User - """ - The list of policy violations if the email address was denied - """ - violations: [String!] -} - -""" -The status of the `addEmail` mutation -""" -enum AddEmailStatus { - """ - The email address was added - """ - ADDED - """ - The email address already exists - """ - EXISTS - """ - The email address is invalid - """ - INVALID - """ - The email address is not allowed by the policy - """ - DENIED -} - -""" -The input for the `addUser` mutation. -""" -input AddUserInput { - """ - The username of the user to add. - """ - username: String! - """ - Skip checking with the homeserver whether the username is valid. - - Use this with caution! The main reason to use this, is when a user used - by an application service needs to exist in MAS to craft special - tokens (like with admin access) for them - """ - skipHomeserverCheck: Boolean -} - -""" -The payload for the `addUser` mutation. -""" -type AddUserPayload { - """ - Status of the operation - """ - status: AddUserStatus! - """ - The user that was added. - """ - user: User -} - -""" -The status of the `addUser` mutation. -""" -enum AddUserStatus { - """ - The user was added. - """ - ADDED - """ - The user already exists. - """ - EXISTS - """ - The username is reserved. - """ - RESERVED - """ - The username is invalid. - """ - INVALID -} - -""" -The input for the `allowUserCrossSigningReset` mutation. -""" -input AllowUserCrossSigningResetInput { - """ - The ID of the user to update. - """ - userId: ID! -} - -""" -The payload for the `allowUserCrossSigningReset` mutation. -""" -type AllowUserCrossSigningResetPayload { - """ - The user that was updated. - """ - user: User -} - -type Anonymous implements Node { - id: ID! -} - -""" -A session in an application, either a compatibility or an OAuth 2.0 one -""" -union AppSession = CompatSession | Oauth2Session - -type AppSessionConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [AppSessionEdge!]! - """ - A list of nodes. - """ - nodes: [AppSession!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type AppSessionEdge { - """ - The item at the end of the edge - """ - node: AppSession! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -An authentication records when a user enter their credential in a browser -session. -""" -type Authentication implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! -} - -""" -A browser session represents a logged in user in a browser. -""" -type BrowserSession implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - The user logged in this session. - """ - user: User! - """ - The most recent authentication of this session. - """ - lastAuthentication: Authentication - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the session was finished. - """ - finishedAt: DateTime - """ - The state of the session. - """ - state: SessionState! - """ - The user-agent with which the session was created. - """ - userAgent: UserAgent - """ - The last IP address used by the session. - """ - lastActiveIp: String - """ - The last time the session was active. - """ - lastActiveAt: DateTime - """ - Get the list of both compat and OAuth 2.0 sessions started by this - browser session, chronologically sorted - """ - appSessions( - """ - List only sessions in the given state. - """ - state: SessionState - """ - List only sessions for the given device. - """ - device: String - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): AppSessionConnection! -} - -type BrowserSessionConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [BrowserSessionEdge!]! - """ - A list of nodes. - """ - nodes: [BrowserSession!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type BrowserSessionEdge { - """ - The item at the end of the edge - """ - node: BrowserSession! - """ - A cursor for use in pagination - """ - cursor: String! -} - -type CaptchaConfig { - """ - Which Captcha service is being used - """ - service: CaptchaService! - """ - The site key used by the instance - """ - siteKey: String! - id: ID! -} - -""" -Which Captcha service is being used -""" -enum CaptchaService { - RECAPTCHA_V2 - CLOUDFLARE_TURNSTILE - H_CAPTCHA -} - -""" -A compat session represents a client session which used the legacy Matrix -login API. -""" -type CompatSession implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - The user authorized for this session. - """ - user: User! - """ - The Matrix Device ID of this session. - """ - deviceId: String - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the session ended. - """ - finishedAt: DateTime - """ - The user-agent with which the session was created. - """ - userAgent: UserAgent - """ - The associated SSO login, if any. - """ - ssoLogin: CompatSsoLogin - """ - The browser session which started this session, if any. - """ - browserSession: BrowserSession - """ - The state of the session. - """ - state: SessionState! - """ - The last IP address used by the session. - """ - lastActiveIp: String - """ - The last time the session was active. - """ - lastActiveAt: DateTime - """ - A human-provided name for the session. - """ - humanName: String -} - -type CompatSessionConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [CompatSessionEdge!]! - """ - A list of nodes. - """ - nodes: [CompatSession!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type CompatSessionEdge { - """ - The item at the end of the edge - """ - node: CompatSession! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -The type of a compatibility session. -""" -enum CompatSessionType { - """ - The session was created by a SSO login. - """ - SSO_LOGIN - """ - The session was created by an unknown method. - """ - UNKNOWN -} - -""" -A compat SSO login represents a login done through the legacy Matrix login -API, via the `m.login.sso` login method. -""" -type CompatSsoLogin implements Node { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! - """ - The redirect URI used during the login. - """ - redirectUri: Url! - """ - When the login was fulfilled, and the user was redirected back to the - client. - """ - fulfilledAt: DateTime - """ - When the client exchanged the login token sent during the redirection. - """ - exchangedAt: DateTime - """ - The compat session which was started by this login. - """ - session: CompatSession -} - -type CompatSsoLoginConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [CompatSsoLoginEdge!]! - """ - A list of nodes. - """ - nodes: [CompatSsoLogin!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type CompatSsoLoginEdge { - """ - The item at the end of the edge - """ - node: CompatSsoLogin! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -The input for the `completeEmailAuthentication` mutation -""" -input CompleteEmailAuthenticationInput { - """ - The authentication code to use - """ - code: String! - """ - The ID of the authentication session to complete - """ - id: ID! -} - -""" -The payload of the `completeEmailAuthentication` mutation -""" -type CompleteEmailAuthenticationPayload { - """ - Status of the operation - """ - status: CompleteEmailAuthenticationStatus! -} - -""" -The status of the `completeEmailAuthentication` mutation -""" -enum CompleteEmailAuthenticationStatus { - """ - The authentication was completed - """ - COMPLETED - """ - The authentication code is invalid - """ - INVALID_CODE - """ - The authentication code has expired - """ - CODE_EXPIRED - """ - Too many attempts to complete an email authentication - """ - RATE_LIMITED - """ - The email address is already in use - """ - IN_USE -} - -""" -The input of the `createOauth2Session` mutation. -""" -input CreateOAuth2SessionInput { - """ - The scope of the session - """ - scope: String! - """ - The ID of the user for which to create the session - """ - userId: ID! - """ - Whether the session should issue a never-expiring access token - """ - permanent: Boolean -} - -""" -The payload of the `createOauth2Session` mutation. -""" -type CreateOAuth2SessionPayload { - """ - Access token for this session - """ - accessToken: String! - """ - Refresh token for this session, if it is not a permanent session - """ - refreshToken: String - """ - The OAuth 2.0 session which was just created - """ - oauth2Session: Oauth2Session! -} - -""" -An object with a creation date. -""" -interface CreationEvent { - """ - When the object was created. - """ - createdAt: DateTime! -} - -""" -A filter for dates, with a lower bound and an upper bound -""" -input DateFilter { - """ - The lower bound of the date range - """ - after: DateTime - """ - The upper bound of the date range - """ - before: DateTime -} - -""" -Implement the DateTime scalar - -The input/output is a string in RFC3339 format. -""" -scalar DateTime - -""" -The input for the `deactivateUser` mutation. -""" -input DeactivateUserInput { - """ - Whether to ask the homeserver to GDPR-erase the user - - This is equivalent to the `erase` parameter on the - `/_matrix/client/v3/account/deactivate` C-S API, which is - implementation-specific. - - What Synapse does is documented here: - - """ - hsErase: Boolean! - """ - The password of the user to deactivate. - """ - password: String -} - -""" -The payload for the `deactivateUser` mutation. -""" -type DeactivateUserPayload { - """ - Status of the operation - """ - status: DeactivateUserStatus! - user: User -} - -""" -The status of the `deactivateUser` mutation. -""" -enum DeactivateUserStatus { - """ - The user was deactivated. - """ - DEACTIVATED - """ - The password was wrong. - """ - INCORRECT_PASSWORD -} - -""" -The type of a user agent -""" -enum DeviceType { - """ - A personal computer, laptop or desktop - """ - PC - """ - A mobile phone. Can also sometimes be a tablet. - """ - MOBILE - """ - A tablet - """ - TABLET - """ - Unknown device type - """ - UNKNOWN -} - -""" -The input of the `endBrowserSession` mutation. -""" -input EndBrowserSessionInput { - """ - The ID of the session to end. - """ - browserSessionId: ID! -} - -type EndBrowserSessionPayload { - """ - The status of the mutation. - """ - status: EndBrowserSessionStatus! - """ - Returns the ended session. - """ - browserSession: BrowserSession -} - -""" -The status of the `endBrowserSession` mutation. -""" -enum EndBrowserSessionStatus { - """ - The session was ended. - """ - ENDED - """ - The session was not found. - """ - NOT_FOUND -} - -""" -The input of the `endCompatSession` mutation. -""" -input EndCompatSessionInput { - """ - The ID of the session to end. - """ - compatSessionId: ID! -} - -type EndCompatSessionPayload { - """ - The status of the mutation. - """ - status: EndCompatSessionStatus! - """ - Returns the ended session. - """ - compatSession: CompatSession -} - -""" -The status of the `endCompatSession` mutation. -""" -enum EndCompatSessionStatus { - """ - The session was ended. - """ - ENDED - """ - The session was not found. - """ - NOT_FOUND -} - -""" -The input of the `endOauth2Session` mutation. -""" -input EndOAuth2SessionInput { - """ - The ID of the session to end. - """ - oauth2SessionId: ID! -} - -type EndOAuth2SessionPayload { - """ - The status of the mutation. - """ - status: EndOAuth2SessionStatus! - """ - Returns the ended session. - """ - oauth2Session: Oauth2Session -} - -""" -The status of the `endOauth2Session` mutation. -""" -enum EndOAuth2SessionStatus { - """ - The session was ended. - """ - ENDED - """ - The session was not found. - """ - NOT_FOUND -} - -""" -The input for the `lockUser` mutation. -""" -input LockUserInput { - """ - The ID of the user to lock. - """ - userId: ID! - """ - Permanently lock the user. - """ - deactivate: Boolean -} - -""" -The payload for the `lockUser` mutation. -""" -type LockUserPayload { - """ - Status of the operation - """ - status: LockUserStatus! - """ - The user that was locked. - """ - user: User -} - -""" -The status of the `lockUser` mutation. -""" -enum LockUserStatus { - """ - The user was locked. - """ - LOCKED - """ - The user was not found. - """ - NOT_FOUND -} - -type MatrixUser { - """ - The Matrix ID of the user. - """ - mxid: String! - """ - The display name of the user, if any. - """ - displayName: String - """ - The avatar URL of the user, if any. - """ - avatarUrl: String - """ - Whether the user is deactivated on the homeserver. - """ - deactivated: Boolean! -} - -""" -The mutations root of the GraphQL interface. -""" -type Mutation { - """ - Add an email address to the specified user - """ - addEmail(input: AddEmailInput!): AddEmailPayload! - @deprecated(reason: "Use `startEmailAuthentication` instead.") - """ - Remove an email address - """ - removeEmail(input: RemoveEmailInput!): RemoveEmailPayload! - """ - Set an email address as primary - """ - setPrimaryEmail(input: SetPrimaryEmailInput!): SetPrimaryEmailPayload! - @deprecated( - reason: "This doesn't do anything anymore, but is kept to avoid breaking existing queries" - ) - """ - Start a new email authentication flow - """ - startEmailAuthentication( - input: StartEmailAuthenticationInput! - ): StartEmailAuthenticationPayload! - """ - Resend the email authentication code - """ - resendEmailAuthenticationCode( - input: ResendEmailAuthenticationCodeInput! - ): ResendEmailAuthenticationCodePayload! - """ - Complete the email authentication flow - """ - completeEmailAuthentication( - input: CompleteEmailAuthenticationInput! - ): CompleteEmailAuthenticationPayload! - """ - Add a user. This is only available to administrators. - """ - addUser(input: AddUserInput!): AddUserPayload! - """ - Lock a user. This is only available to administrators. - """ - lockUser(input: LockUserInput!): LockUserPayload! - """ - Unlock and reactivate a user. This is only available to administrators. - """ - unlockUser(input: UnlockUserInput!): UnlockUserPayload! - """ - Set whether a user can request admin. This is only available to - administrators. - """ - setCanRequestAdmin( - input: SetCanRequestAdminInput! - ): SetCanRequestAdminPayload! - """ - Temporarily allow user to reset their cross-signing keys. - """ - allowUserCrossSigningReset( - input: AllowUserCrossSigningResetInput! - ): AllowUserCrossSigningResetPayload! - """ - Set the password for a user. - - This can be used by server administrators to set any user's password, - or, provided the capability hasn't been disabled on this server, - by a user to change their own password as long as they know their - current password. - """ - setPassword(input: SetPasswordInput!): SetPasswordPayload! - """ - Set the password for yourself, using a recovery ticket sent by e-mail. - """ - setPasswordByRecovery(input: SetPasswordByRecoveryInput!): SetPasswordPayload! - """ - Resend a user recovery email - - This is used when a user opens a recovery link that has expired. In this - case, we display a link for them to get a new recovery email, which - calls this mutation. - """ - resendRecoveryEmail( - input: ResendRecoveryEmailInput! - ): ResendRecoveryEmailPayload! - """ - Deactivate the current user account - - If the user has a password, it *must* be supplied in the `password` - field. - """ - deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload! - """ - Create a new arbitrary OAuth 2.0 Session. - - Only available for administrators. - """ - createOauth2Session( - input: CreateOAuth2SessionInput! - ): CreateOAuth2SessionPayload! - endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! - setOauth2SessionName( - input: SetOAuth2SessionNameInput! - ): SetOAuth2SessionNamePayload! - endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! - setCompatSessionName( - input: SetCompatSessionNameInput! - ): SetCompatSessionNamePayload! - endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! - """ - Set the display name of a user - """ - setDisplayName(input: SetDisplayNameInput!): SetDisplayNamePayload! -} - -""" -An object with an ID. -""" -interface Node { - """ - ID of the object. - """ - id: ID! -} - -""" -The application type advertised by the client. -""" -enum Oauth2ApplicationType { - """ - Client is a web application. - """ - WEB - """ - Client is a native application. - """ - NATIVE -} - -""" -An OAuth 2.0 client -""" -type Oauth2Client implements Node { - """ - ID of the object. - """ - id: ID! - """ - OAuth 2.0 client ID - """ - clientId: String! - """ - Client name advertised by the client. - """ - clientName: String - """ - Client URI advertised by the client. - """ - clientUri: Url - """ - Logo URI advertised by the client. - """ - logoUri: Url - """ - Terms of services URI advertised by the client. - """ - tosUri: Url - """ - Privacy policy URI advertised by the client. - """ - policyUri: Url - """ - List of redirect URIs used for authorization grants by the client. - """ - redirectUris: [Url!]! - """ - The application type advertised by the client. - """ - applicationType: Oauth2ApplicationType -} - -""" -An OAuth 2.0 session represents a client session which used the OAuth APIs -to login. -""" -type Oauth2Session implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - OAuth 2.0 client used by this session. - """ - client: Oauth2Client! - """ - Scope granted for this session. - """ - scope: String! - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the session ended. - """ - finishedAt: DateTime - """ - The user-agent with which the session was created. - """ - userAgent: UserAgent - """ - The state of the session. - """ - state: SessionState! - """ - The browser session which started this OAuth 2.0 session. - """ - browserSession: BrowserSession - """ - User authorized for this session. - """ - user: User - """ - The last IP address used by the session. - """ - lastActiveIp: String - """ - The last time the session was active. - """ - lastActiveAt: DateTime - """ - The user-provided name for this session. - """ - humanName: String -} - -type Oauth2SessionConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [Oauth2SessionEdge!]! - """ - A list of nodes. - """ - nodes: [Oauth2Session!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type Oauth2SessionEdge { - """ - The item at the end of the edge - """ - node: Oauth2Session! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -Information about pagination in a connection -""" -type PageInfo { - """ - When paginating backwards, are there more items? - """ - hasPreviousPage: Boolean! - """ - When paginating forwards, are there more items? - """ - hasNextPage: Boolean! - """ - When paginating backwards, the cursor to continue. - """ - startCursor: String - """ - When paginating forwards, the cursor to continue. - """ - endCursor: String -} - -""" -The query root of the GraphQL interface. -""" -type Query { - """ - Get the current logged in browser session - """ - currentBrowserSession: BrowserSession - @deprecated(reason: "Use `viewerSession` instead.") - """ - Get the current logged in user - """ - currentUser: User @deprecated(reason: "Use `viewer` instead.") - """ - Fetch an OAuth 2.0 client by its ID. - """ - oauth2Client(id: ID!): Oauth2Client - """ - Fetch a browser session by its ID. - """ - browserSession(id: ID!): BrowserSession - """ - Fetch a compatible session by its ID. - """ - compatSession(id: ID!): CompatSession - """ - Fetch an OAuth 2.0 session by its ID. - """ - oauth2Session(id: ID!): Oauth2Session - """ - Fetch a user email by its ID. - """ - userEmail(id: ID!): UserEmail - """ - Fetch a user recovery ticket. - """ - userRecoveryTicket(ticket: String!): UserRecoveryTicket - """ - Fetch a user email authentication session - """ - userEmailAuthentication(id: ID!): UserEmailAuthentication - """ - Fetches an object given its ID. - """ - node(id: ID!): Node - """ - Get the current site configuration - """ - siteConfig: SiteConfig! - """ - Fetch a user by its ID. - """ - user(id: ID!): User - """ - Fetch a user by its username. - """ - userByUsername(username: String!): User - """ - Get a list of users. - - This is only available to administrators. - """ - users( - """ - List only users with the given state. - """ - state: UserState - """ - List only users with the given 'canRequestAdmin' value - """ - canRequestAdmin: Boolean - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): UserConnection! - """ - Fetch an upstream OAuth 2.0 link by its ID. - """ - upstreamOauth2Link(id: ID!): UpstreamOAuth2Link - """ - Fetch an upstream OAuth 2.0 provider by its ID. - """ - upstreamOauth2Provider(id: ID!): UpstreamOAuth2Provider - """ - Get a list of upstream OAuth 2.0 providers. - """ - upstreamOauth2Providers( - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): UpstreamOAuth2ProviderConnection! - """ - Lookup a compat or OAuth 2.0 session - """ - session(userId: ID!, deviceId: String!): Session - """ - Get the viewer - """ - viewer: Viewer! - """ - Get the viewer's session - """ - viewerSession: ViewerSession! -} - -""" -The input for the `removeEmail` mutation -""" -input RemoveEmailInput { - """ - The ID of the email address to remove - """ - userEmailId: ID! - """ - The user's current password. This is required if the user is not an - admin and it has a password on its account. - """ - password: String -} - -""" -The payload of the `removeEmail` mutation -""" -type RemoveEmailPayload { - """ - Status of the operation - """ - status: RemoveEmailStatus! - """ - The email address that was removed - """ - email: UserEmail - """ - The user to whom the email address belonged - """ - user: User -} - -""" -The status of the `removeEmail` mutation -""" -enum RemoveEmailStatus { - """ - The email address was removed - """ - REMOVED - """ - The email address was not found - """ - NOT_FOUND - """ - The password provided is incorrect - """ - INCORRECT_PASSWORD -} - -""" -The input for the `resendEmailAuthenticationCode` mutation -""" -input ResendEmailAuthenticationCodeInput { - """ - The ID of the authentication session to resend the code for - """ - id: ID! - """ - The language to use for the email - """ - language: String! = "en" -} - -""" -The payload of the `resendEmailAuthenticationCode` mutation -""" -type ResendEmailAuthenticationCodePayload { - """ - Status of the operation - """ - status: ResendEmailAuthenticationCodeStatus! -} - -""" -The status of the `resendEmailAuthenticationCode` mutation -""" -enum ResendEmailAuthenticationCodeStatus { - """ - The email was resent - """ - RESENT - """ - The email authentication session is already completed - """ - COMPLETED - """ - Too many attempts to resend an email authentication code - """ - RATE_LIMITED -} - -""" -The input for the `resendRecoveryEmail` mutation. -""" -input ResendRecoveryEmailInput { - """ - The recovery ticket to use. - """ - ticket: String! -} - -""" -The return type for the `resendRecoveryEmail` mutation. -""" -type ResendRecoveryEmailPayload { - """ - Status of the operation - """ - status: ResendRecoveryEmailStatus! - """ - URL to continue the recovery process - """ - progressUrl: Url -} - -""" -The status of the `resendRecoveryEmail` mutation. -""" -enum ResendRecoveryEmailStatus { - """ - The recovery ticket was not found. - """ - NO_SUCH_RECOVERY_TICKET - """ - The rate limit was exceeded. - """ - RATE_LIMITED - """ - The recovery email was sent. - """ - SENT -} - -""" -A client session, either compat or OAuth 2.0 -""" -union Session = CompatSession | Oauth2Session - -""" -The state of a session -""" -enum SessionState { - """ - The session is active. - """ - ACTIVE - """ - The session is no longer active. - """ - FINISHED -} - -""" -The input for the `setCanRequestAdmin` mutation. -""" -input SetCanRequestAdminInput { - """ - The ID of the user to update. - """ - userId: ID! - """ - Whether the user can request admin. - """ - canRequestAdmin: Boolean! -} - -""" -The payload for the `setCanRequestAdmin` mutation. -""" -type SetCanRequestAdminPayload { - """ - The user that was updated. - """ - user: User -} - -""" -The input of the `setCompatSessionName` mutation. -""" -input SetCompatSessionNameInput { - """ - The ID of the session to set the name of. - """ - compatSessionId: ID! - """ - The new name of the session. - """ - humanName: String! -} - -type SetCompatSessionNamePayload { - """ - The status of the mutation. - """ - status: SetCompatSessionNameStatus! - """ - The session that was updated. - """ - oauth2Session: CompatSession -} - -""" -The status of the `setCompatSessionName` mutation. -""" -enum SetCompatSessionNameStatus { - """ - The session was updated. - """ - UPDATED - """ - The session was not found. - """ - NOT_FOUND -} - -""" -The input for the `addEmail` mutation -""" -input SetDisplayNameInput { - """ - The ID of the user to add the email address to - """ - userId: ID! - """ - The display name to set. If `None`, the display name will be removed. - """ - displayName: String -} - -""" -The payload of the `setDisplayName` mutation -""" -type SetDisplayNamePayload { - """ - Status of the operation - """ - status: SetDisplayNameStatus! - """ - The user that was updated - """ - user: User -} - -""" -The status of the `setDisplayName` mutation -""" -enum SetDisplayNameStatus { - """ - The display name was set - """ - SET - """ - The display name is invalid - """ - INVALID -} - -""" -The input of the `setOauth2SessionName` mutation. -""" -input SetOAuth2SessionNameInput { - """ - The ID of the session to set the name of. - """ - oauth2SessionId: ID! - """ - The new name of the session. - """ - humanName: String! -} - -type SetOAuth2SessionNamePayload { - """ - The status of the mutation. - """ - status: SetOAuth2SessionNameStatus! - """ - The session that was updated. - """ - oauth2Session: Oauth2Session -} - -""" -The status of the `setOauth2SessionName` mutation. -""" -enum SetOAuth2SessionNameStatus { - """ - The session was updated. - """ - UPDATED - """ - The session was not found. - """ - NOT_FOUND -} - -""" -The input for the `setPasswordByRecovery` mutation. -""" -input SetPasswordByRecoveryInput { - """ - The recovery ticket to use. - This identifies the user as well as proving authorisation to perform the - recovery operation. - """ - ticket: String! - """ - The new password for the user. - """ - newPassword: String! -} - -""" -The input for the `setPassword` mutation. -""" -input SetPasswordInput { - """ - The ID of the user to set the password for. - If you are not a server administrator then this must be your own user - ID. - """ - userId: ID! - """ - The current password of the user. - Required if you are not a server administrator. - """ - currentPassword: String - """ - The new password for the user. - """ - newPassword: String! -} - -""" -The return type for the `setPassword` mutation. -""" -type SetPasswordPayload { - """ - Status of the operation - """ - status: SetPasswordStatus! -} - -""" -The status of the `setPassword` mutation. -""" -enum SetPasswordStatus { - """ - The password was updated. - """ - ALLOWED - """ - The user was not found. - """ - NOT_FOUND - """ - The user doesn't have a current password to attempt to match against. - """ - NO_CURRENT_PASSWORD - """ - The supplied current password was wrong. - """ - WRONG_PASSWORD - """ - The new password is invalid. For example, it may not meet configured - security requirements. - """ - INVALID_NEW_PASSWORD - """ - You aren't allowed to set the password for that user. - This happens if you aren't setting your own password and you aren't a - server administrator. - """ - NOT_ALLOWED - """ - Password support has been disabled. - This usually means that login is handled by an upstream identity - provider. - """ - PASSWORD_CHANGES_DISABLED - """ - The specified recovery ticket does not exist. - """ - NO_SUCH_RECOVERY_TICKET - """ - The specified recovery ticket has already been used and cannot be used - again. - """ - RECOVERY_TICKET_ALREADY_USED - """ - The specified recovery ticket has expired. - """ - EXPIRED_RECOVERY_TICKET - """ - Your account is locked and you can't change its password. - """ - ACCOUNT_LOCKED -} - -""" -The input for the `setPrimaryEmail` mutation -""" -input SetPrimaryEmailInput { - """ - The ID of the email address to set as primary - """ - userEmailId: ID! -} - -""" -The payload of the `setPrimaryEmail` mutation -""" -type SetPrimaryEmailPayload { - status: SetPrimaryEmailStatus! - """ - The user to whom the email address belongs - """ - user: User -} - -""" -The status of the `setPrimaryEmail` mutation -""" -enum SetPrimaryEmailStatus { - """ - The email address was set as primary - """ - SET - """ - The email address was not found - """ - NOT_FOUND - """ - Can't make an unverified email address primary - """ - UNVERIFIED -} - -type SiteConfig implements Node { - """ - The configuration of CAPTCHA provider. - """ - captchaConfig: CaptchaConfig - """ - The server name of the homeserver. - """ - serverName: String! - """ - The URL to the privacy policy. - """ - policyUri: Url - """ - The URL to the terms of service. - """ - tosUri: Url - """ - Imprint to show in the footer. - """ - imprint: String - """ - Whether users can change their email. - """ - emailChangeAllowed: Boolean! - """ - Whether users can change their display name. - """ - displayNameChangeAllowed: Boolean! - """ - Whether passwords are enabled for login. - """ - passwordLoginEnabled: Boolean! - """ - Whether passwords are enabled and users can change their own passwords. - """ - passwordChangeAllowed: Boolean! - """ - Whether passwords are enabled and users can register using a password. - """ - passwordRegistrationEnabled: Boolean! - """ - Whether users can delete their own account. - """ - accountDeactivationAllowed: Boolean! - """ - Minimum password complexity, from 0 to 4, in terms of a zxcvbn score. - The exact scorer (including dictionaries and other data tables) - in use is . - """ - minimumPasswordComplexity: Int! - """ - Whether users can log in with their email address. - """ - loginWithEmailAllowed: Boolean! - """ - Experimental plan management iframe URI. - """ - planManagementIframeUri: String - """ - The ID of the site configuration. - """ - id: ID! -} - -""" -The input for the `startEmailAuthentication` mutation -""" -input StartEmailAuthenticationInput { - """ - The email address to add to the account - """ - email: String! - """ - The user's current password. This is required if the user has a password - on its account. - """ - password: String - """ - The language to use for the email - """ - language: String! = "en" -} - -""" -The payload of the `startEmailAuthentication` mutation -""" -type StartEmailAuthenticationPayload { - """ - Status of the operation - """ - status: StartEmailAuthenticationStatus! - """ - The email authentication session that was started - """ - authentication: UserEmailAuthentication - """ - The list of policy violations if the email address was denied - """ - violations: [String!] -} - -""" -The status of the `startEmailAuthentication` mutation -""" -enum StartEmailAuthenticationStatus { - """ - The email address was started - """ - STARTED - """ - The email address is invalid - """ - INVALID_EMAIL_ADDRESS - """ - Too many attempts to start an email authentication - """ - RATE_LIMITED - """ - The email address isn't allowed by the policy - """ - DENIED - """ - The email address is already in use on this account - """ - IN_USE - """ - The password provided is incorrect - """ - INCORRECT_PASSWORD -} - -""" -The input for the `unlockUser` mutation. -""" -input UnlockUserInput { - """ - The ID of the user to unlock - """ - userId: ID! -} - -""" -The payload for the `unlockUser` mutation. -""" -type UnlockUserPayload { - """ - Status of the operation - """ - status: UnlockUserStatus! - """ - The user that was unlocked. - """ - user: User -} - -""" -The status of the `unlockUser` mutation. -""" -enum UnlockUserStatus { - """ - The user was unlocked. - """ - UNLOCKED - """ - The user was not found. - """ - NOT_FOUND -} - -type UpstreamOAuth2Link implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! - """ - Subject used for linking - """ - subject: String! - """ - A human-readable name for the link subject. - """ - humanAccountName: String - """ - The provider for which this link is. - """ - provider: UpstreamOAuth2Provider! - """ - The user to which this link is associated. - """ - user: User -} - -type UpstreamOAuth2LinkConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [UpstreamOAuth2LinkEdge!]! - """ - A list of nodes. - """ - nodes: [UpstreamOAuth2Link!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type UpstreamOAuth2LinkEdge { - """ - The item at the end of the edge - """ - node: UpstreamOAuth2Link! - """ - A cursor for use in pagination - """ - cursor: String! -} - -type UpstreamOAuth2Provider implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! - """ - OpenID Connect issuer URL. - """ - issuer: String - """ - Client ID used for this provider. - """ - clientId: String! - """ - A human-readable name for this provider. - """ - humanName: String - """ - A brand identifier for this provider. - - One of `google`, `github`, `gitlab`, `apple` or `facebook`. - """ - brandName: String - """ - URL to start the linking process of the current user with this provider. - """ - linkUrl: Url! -} - -type UpstreamOAuth2ProviderConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [UpstreamOAuth2ProviderEdge!]! - """ - A list of nodes. - """ - nodes: [UpstreamOAuth2Provider!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type UpstreamOAuth2ProviderEdge { - """ - The item at the end of the edge - """ - node: UpstreamOAuth2Provider! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -URL is a String implementing the [URL Standard](http://url.spec.whatwg.org/) -""" -scalar Url - -""" -A user is an individual's account. -""" -type User implements Node { - """ - ID of the object. - """ - id: ID! - """ - Username chosen by the user. - """ - username: String! - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the user was locked out. - """ - lockedAt: DateTime - """ - Whether the user can request admin privileges. - """ - canRequestAdmin: Boolean! - """ - Access to the user's Matrix account information. - """ - matrix: MatrixUser! - """ - Get the list of compatibility SSO logins, chronologically sorted - """ - compatSsoLogins( - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): CompatSsoLoginConnection! - """ - Get the list of compatibility sessions, chronologically sorted - """ - compatSessions( - """ - List only sessions with the given state. - """ - state: SessionState - """ - List only sessions with the given type. - """ - type: CompatSessionType - """ - List only sessions with a last active time is between the given bounds. - """ - lastActive: DateFilter - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): CompatSessionConnection! - """ - Get the list of active browser sessions, chronologically sorted - """ - browserSessions( - """ - List only sessions in the given state. - """ - state: SessionState - """ - List only sessions with a last active time is between the given bounds. - """ - lastActive: DateFilter - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): BrowserSessionConnection! - """ - Get the list of emails, chronologically sorted - """ - emails( - """ - List only emails in the given state. - """ - state: UserEmailState - @deprecated( - reason: "Emails are always confirmed, and have only one state" - ) - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): UserEmailConnection! - """ - Get the list of OAuth 2.0 sessions, chronologically sorted - """ - oauth2Sessions( - """ - List only sessions in the given state. - """ - state: SessionState - """ - List only sessions for the given client. - """ - client: ID - """ - List only sessions with a last active time is between the given bounds. - """ - lastActive: DateFilter - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): Oauth2SessionConnection! - """ - Get the list of upstream OAuth 2.0 links - """ - upstreamOauth2Links( - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): UpstreamOAuth2LinkConnection! - """ - Get the list of both compat and OAuth 2.0 sessions, chronologically - sorted - """ - appSessions( - """ - List only sessions in the given state. - """ - state: SessionState - """ - List only sessions for the given device. - """ - device: String - """ - List only sessions with a last active time is between the given bounds. - """ - lastActive: DateFilter - """ - List only sessions for the given session. - """ - browserSession: ID - """ - Returns the elements in the list that come after the cursor. - """ - after: String - """ - Returns the elements in the list that come before the cursor. - """ - before: String - """ - Returns the first *n* elements from the list. - """ - first: Int - """ - Returns the last *n* elements from the list. - """ - last: Int - ): AppSessionConnection! - """ - Check if the user has a password set. - """ - hasPassword: Boolean! -} - -""" -A parsed user agent string -""" -type UserAgent { - """ - The user agent string - """ - raw: String! - """ - The name of the browser - """ - name: String - """ - The version of the browser - """ - version: String - """ - The operating system name - """ - os: String - """ - The operating system version - """ - osVersion: String - """ - The device model - """ - model: String - """ - The device type - """ - deviceType: DeviceType! -} - -type UserConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [UserEdge!]! - """ - A list of nodes. - """ - nodes: [User!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type UserEdge { - """ - The item at the end of the edge - """ - node: User! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -A user email address -""" -type UserEmail implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - Email address - """ - email: String! - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the email address was confirmed. Is `null` if the email was never - verified by the user. - """ - confirmedAt: DateTime @deprecated(reason: "Emails are always confirmed now.") -} - -""" -A email authentication session -""" -type UserEmailAuthentication implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! - """ - When the object was last updated. - """ - completedAt: DateTime - """ - The email address associated with this session - """ - email: String! -} - -type UserEmailConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [UserEmailEdge!]! - """ - A list of nodes. - """ - nodes: [UserEmail!]! - """ - Identifies the total count of items in the connection. - """ - totalCount: Int! -} - -""" -An edge in a connection. -""" -type UserEmailEdge { - """ - The item at the end of the edge - """ - node: UserEmail! - """ - A cursor for use in pagination - """ - cursor: String! -} - -""" -The state of a compatibility session. -""" -enum UserEmailState { - """ - The email address is pending confirmation. - """ - PENDING - """ - The email address has been confirmed. - """ - CONFIRMED -} - -""" -A recovery ticket -""" -type UserRecoveryTicket implements Node & CreationEvent { - """ - ID of the object. - """ - id: ID! - """ - When the object was created. - """ - createdAt: DateTime! - """ - The status of the ticket - """ - status: UserRecoveryTicketStatus! - """ - The username associated with this ticket - """ - username: String! - """ - The email address associated with this ticket - """ - email: String! -} - -""" -The status of a recovery ticket -""" -enum UserRecoveryTicketStatus { - """ - The ticket is valid - """ - VALID - """ - The ticket has expired - """ - EXPIRED - """ - The ticket has been consumed - """ - CONSUMED -} - -""" -The state of a user. -""" -enum UserState { - """ - The user is active. - """ - ACTIVE - """ - The user is locked. - """ - LOCKED -} - -""" -Represents the current viewer -""" -union Viewer = User | Anonymous - -""" -Represents the current viewer's session -""" -union ViewerSession = BrowserSession | Oauth2Session | Anonymous - -""" -Marks an element of a GraphQL schema as no longer supported. -""" -directive @deprecated( - reason: String = "No longer supported" -) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE -""" -Directs the executor to include this field or fragment only when the `if` argument is true. -""" -directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -""" -Directs the executor to skip this field or fragment when the `if` argument is true. -""" -directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -""" -Provides a scalar specification URL for specifying the behavior of custom scalar types. -""" -directive @specifiedBy(url: String!) on SCALAR -schema { - query: Query - mutation: Mutation -} From a2eba2891ff24ceda3a04d87ca39c7ce16cfeb35 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 15:01:35 -0500 Subject: [PATCH 09/70] Fix type problems --- crates/handlers/src/compat/login.rs | 6 +++--- crates/handlers/src/compat/login_sso_complete.rs | 4 ++-- crates/policy/src/model.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 92f35914e..48ba23f9a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -594,7 +594,7 @@ async fn token_login( user: &browser_session.user, login: CompatLogin::Token, session_replaced, - session_counts, + session_counts: &session_counts, requester, }) .await?; @@ -730,7 +730,7 @@ async fn user_password_login( user: &user, login: CompatLogin::Password, session_replaced, - session_counts, + session_counts: &session_counts, requester: policy_requester, }) .await?; @@ -781,7 +781,7 @@ async fn user_password_login( let mut sessions_removed = 0; for edge in compat.edges { let (compat_session, _) = edge.node; - let compat_session = + let _compat_session = repo.compat_session().finish(clock, compat_session).await?; sessions_removed += 1; } diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index df059cd36..0e93edafd 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -123,7 +123,7 @@ pub async fn get( // We don't know if there's going to be a replacement until we received the device ID, // which happens too late. session_replaced: false, - session_counts, + session_counts: &session_counts, requester: mas_policy::Requester { ip_address: activity_tracker.ip(), user_agent, @@ -268,7 +268,7 @@ pub async fn post( login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, - session_counts, + session_counts: &session_counts, // We don't know if there's going to be a replacement until we received the device ID, // which happens too late. session_replaced: false, diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index a3bf24b5f..68ef49195 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -202,7 +202,7 @@ pub struct CompatLoginInput<'a> { pub user: &'a User, /// How many sessions the user has. - pub session_counts: SessionCounts, + pub session_counts: &'a SessionCounts, /// Whether a session will be replaced by this login pub session_replaced: bool, From 6f2f63238a6db83dba63cf5de9b37be716d89562 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 15:10:45 -0500 Subject: [PATCH 10/70] Comment in better place --- crates/handlers/src/compat/login.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 48ba23f9a..2c4af6930 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -764,6 +764,10 @@ async fn user_password_login( } // Find the least recently used compat sessions + // + // In the future, it may be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys uploaded + // for device?). let compat = repo .compat_session() .list( @@ -791,10 +795,6 @@ async fn user_password_login( // For now, we only automatically clean up compatibility sessions. // If there are still too many sessions, we just throw an error with // an explanation. - // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). if sessions_removed < need_to_remove { return Err(RouteError::PolicyHardSessionLimitReached); } From d75b37c126dc60f59f793d8c8f23a102a5ceae51 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 15:13:16 -0500 Subject: [PATCH 11/70] Run `sh misc/update.sh` --- frontend/schema.graphql | 2491 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 2491 insertions(+) diff --git a/frontend/schema.graphql b/frontend/schema.graphql index e69de29bb..99da32010 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -0,0 +1,2491 @@ +""" +The input for the `addEmail` mutation +""" +input AddEmailInput { + """ + The email address to add + """ + email: String! + """ + The ID of the user to add the email address to + """ + userId: ID! + """ + Skip the email address verification. Only allowed for admins. + """ + skipVerification: Boolean + """ + Skip the email address policy check. Only allowed for admins. + """ + skipPolicyCheck: Boolean +} + +""" +The payload of the `addEmail` mutation +""" +type AddEmailPayload { + """ + Status of the operation + """ + status: AddEmailStatus! + """ + The email address that was added + """ + email: UserEmail + """ + The user to whom the email address was added + """ + user: User + """ + The list of policy violations if the email address was denied + """ + violations: [String!] +} + +""" +The status of the `addEmail` mutation +""" +enum AddEmailStatus { + """ + The email address was added + """ + ADDED + """ + The email address already exists + """ + EXISTS + """ + The email address is invalid + """ + INVALID + """ + The email address is not allowed by the policy + """ + DENIED +} + +""" +The input for the `addUser` mutation. +""" +input AddUserInput { + """ + The username of the user to add. + """ + username: String! + """ + Skip checking with the homeserver whether the username is valid. + + Use this with caution! The main reason to use this, is when a user used + by an application service needs to exist in MAS to craft special + tokens (like with admin access) for them + """ + skipHomeserverCheck: Boolean +} + +""" +The payload for the `addUser` mutation. +""" +type AddUserPayload { + """ + Status of the operation + """ + status: AddUserStatus! + """ + The user that was added. + """ + user: User +} + +""" +The status of the `addUser` mutation. +""" +enum AddUserStatus { + """ + The user was added. + """ + ADDED + """ + The user already exists. + """ + EXISTS + """ + The username is reserved. + """ + RESERVED + """ + The username is invalid. + """ + INVALID +} + +""" +The input for the `allowUserCrossSigningReset` mutation. +""" +input AllowUserCrossSigningResetInput { + """ + The ID of the user to update. + """ + userId: ID! +} + +""" +The payload for the `allowUserCrossSigningReset` mutation. +""" +type AllowUserCrossSigningResetPayload { + """ + The user that was updated. + """ + user: User +} + +type Anonymous implements Node { + id: ID! +} + +""" +A session in an application, either a compatibility or an OAuth 2.0 one +""" +union AppSession = CompatSession | Oauth2Session + +type AppSessionConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [AppSessionEdge!]! + """ + A list of nodes. + """ + nodes: [AppSession!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type AppSessionEdge { + """ + The item at the end of the edge + """ + node: AppSession! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +An authentication records when a user enter their credential in a browser +session. +""" +type Authentication implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! +} + +""" +A browser session represents a logged in user in a browser. +""" +type BrowserSession implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + The user logged in this session. + """ + user: User! + """ + The most recent authentication of this session. + """ + lastAuthentication: Authentication + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the session was finished. + """ + finishedAt: DateTime + """ + The state of the session. + """ + state: SessionState! + """ + The user-agent with which the session was created. + """ + userAgent: UserAgent + """ + The last IP address used by the session. + """ + lastActiveIp: String + """ + The last time the session was active. + """ + lastActiveAt: DateTime + """ + Get the list of both compat and OAuth 2.0 sessions started by this + browser session, chronologically sorted + """ + appSessions( + """ + List only sessions in the given state. + """ + state: SessionState + """ + List only sessions for the given device. + """ + device: String + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): AppSessionConnection! +} + +type BrowserSessionConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [BrowserSessionEdge!]! + """ + A list of nodes. + """ + nodes: [BrowserSession!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type BrowserSessionEdge { + """ + The item at the end of the edge + """ + node: BrowserSession! + """ + A cursor for use in pagination + """ + cursor: String! +} + +type CaptchaConfig { + """ + Which Captcha service is being used + """ + service: CaptchaService! + """ + The site key used by the instance + """ + siteKey: String! + id: ID! +} + +""" +Which Captcha service is being used +""" +enum CaptchaService { + RECAPTCHA_V2 + CLOUDFLARE_TURNSTILE + H_CAPTCHA +} + +""" +A compat session represents a client session which used the legacy Matrix +login API. +""" +type CompatSession implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + The user authorized for this session. + """ + user: User! + """ + The Matrix Device ID of this session. + """ + deviceId: String + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the session ended. + """ + finishedAt: DateTime + """ + The user-agent with which the session was created. + """ + userAgent: UserAgent + """ + The associated SSO login, if any. + """ + ssoLogin: CompatSsoLogin + """ + The browser session which started this session, if any. + """ + browserSession: BrowserSession + """ + The state of the session. + """ + state: SessionState! + """ + The last IP address used by the session. + """ + lastActiveIp: String + """ + The last time the session was active. + """ + lastActiveAt: DateTime + """ + A human-provided name for the session. + """ + humanName: String +} + +type CompatSessionConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [CompatSessionEdge!]! + """ + A list of nodes. + """ + nodes: [CompatSession!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type CompatSessionEdge { + """ + The item at the end of the edge + """ + node: CompatSession! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +The type of a compatibility session. +""" +enum CompatSessionType { + """ + The session was created by a SSO login. + """ + SSO_LOGIN + """ + The session was created by an unknown method. + """ + UNKNOWN +} + +""" +A compat SSO login represents a login done through the legacy Matrix login +API, via the `m.login.sso` login method. +""" +type CompatSsoLogin implements Node { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + The redirect URI used during the login. + """ + redirectUri: Url! + """ + When the login was fulfilled, and the user was redirected back to the + client. + """ + fulfilledAt: DateTime + """ + When the client exchanged the login token sent during the redirection. + """ + exchangedAt: DateTime + """ + The compat session which was started by this login. + """ + session: CompatSession +} + +type CompatSsoLoginConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [CompatSsoLoginEdge!]! + """ + A list of nodes. + """ + nodes: [CompatSsoLogin!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type CompatSsoLoginEdge { + """ + The item at the end of the edge + """ + node: CompatSsoLogin! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +The input for the `completeEmailAuthentication` mutation +""" +input CompleteEmailAuthenticationInput { + """ + The authentication code to use + """ + code: String! + """ + The ID of the authentication session to complete + """ + id: ID! +} + +""" +The payload of the `completeEmailAuthentication` mutation +""" +type CompleteEmailAuthenticationPayload { + """ + Status of the operation + """ + status: CompleteEmailAuthenticationStatus! +} + +""" +The status of the `completeEmailAuthentication` mutation +""" +enum CompleteEmailAuthenticationStatus { + """ + The authentication was completed + """ + COMPLETED + """ + The authentication code is invalid + """ + INVALID_CODE + """ + The authentication code has expired + """ + CODE_EXPIRED + """ + Too many attempts to complete an email authentication + """ + RATE_LIMITED + """ + The email address is already in use + """ + IN_USE +} + +""" +The input of the `createOauth2Session` mutation. +""" +input CreateOAuth2SessionInput { + """ + The scope of the session + """ + scope: String! + """ + The ID of the user for which to create the session + """ + userId: ID! + """ + Whether the session should issue a never-expiring access token + """ + permanent: Boolean +} + +""" +The payload of the `createOauth2Session` mutation. +""" +type CreateOAuth2SessionPayload { + """ + Access token for this session + """ + accessToken: String! + """ + Refresh token for this session, if it is not a permanent session + """ + refreshToken: String + """ + The OAuth 2.0 session which was just created + """ + oauth2Session: Oauth2Session! +} + +""" +An object with a creation date. +""" +interface CreationEvent { + """ + When the object was created. + """ + createdAt: DateTime! +} + +""" +A filter for dates, with a lower bound and an upper bound +""" +input DateFilter { + """ + The lower bound of the date range + """ + after: DateTime + """ + The upper bound of the date range + """ + before: DateTime +} + +""" +Implement the DateTime scalar + +The input/output is a string in RFC3339 format. +""" +scalar DateTime + +""" +The input for the `deactivateUser` mutation. +""" +input DeactivateUserInput { + """ + Whether to ask the homeserver to GDPR-erase the user + + This is equivalent to the `erase` parameter on the + `/_matrix/client/v3/account/deactivate` C-S API, which is + implementation-specific. + + What Synapse does is documented here: + + """ + hsErase: Boolean! + """ + The password of the user to deactivate. + """ + password: String +} + +""" +The payload for the `deactivateUser` mutation. +""" +type DeactivateUserPayload { + """ + Status of the operation + """ + status: DeactivateUserStatus! + user: User +} + +""" +The status of the `deactivateUser` mutation. +""" +enum DeactivateUserStatus { + """ + The user was deactivated. + """ + DEACTIVATED + """ + The password was wrong. + """ + INCORRECT_PASSWORD +} + +""" +The type of a user agent +""" +enum DeviceType { + """ + A personal computer, laptop or desktop + """ + PC + """ + A mobile phone. Can also sometimes be a tablet. + """ + MOBILE + """ + A tablet + """ + TABLET + """ + Unknown device type + """ + UNKNOWN +} + +""" +The input of the `endBrowserSession` mutation. +""" +input EndBrowserSessionInput { + """ + The ID of the session to end. + """ + browserSessionId: ID! +} + +type EndBrowserSessionPayload { + """ + The status of the mutation. + """ + status: EndBrowserSessionStatus! + """ + Returns the ended session. + """ + browserSession: BrowserSession +} + +""" +The status of the `endBrowserSession` mutation. +""" +enum EndBrowserSessionStatus { + """ + The session was ended. + """ + ENDED + """ + The session was not found. + """ + NOT_FOUND +} + +""" +The input of the `endCompatSession` mutation. +""" +input EndCompatSessionInput { + """ + The ID of the session to end. + """ + compatSessionId: ID! +} + +type EndCompatSessionPayload { + """ + The status of the mutation. + """ + status: EndCompatSessionStatus! + """ + Returns the ended session. + """ + compatSession: CompatSession +} + +""" +The status of the `endCompatSession` mutation. +""" +enum EndCompatSessionStatus { + """ + The session was ended. + """ + ENDED + """ + The session was not found. + """ + NOT_FOUND +} + +""" +The input of the `endOauth2Session` mutation. +""" +input EndOAuth2SessionInput { + """ + The ID of the session to end. + """ + oauth2SessionId: ID! +} + +type EndOAuth2SessionPayload { + """ + The status of the mutation. + """ + status: EndOAuth2SessionStatus! + """ + Returns the ended session. + """ + oauth2Session: Oauth2Session +} + +""" +The status of the `endOauth2Session` mutation. +""" +enum EndOAuth2SessionStatus { + """ + The session was ended. + """ + ENDED + """ + The session was not found. + """ + NOT_FOUND +} + +""" +The input for the `lockUser` mutation. +""" +input LockUserInput { + """ + The ID of the user to lock. + """ + userId: ID! + """ + Permanently lock the user. + """ + deactivate: Boolean +} + +""" +The payload for the `lockUser` mutation. +""" +type LockUserPayload { + """ + Status of the operation + """ + status: LockUserStatus! + """ + The user that was locked. + """ + user: User +} + +""" +The status of the `lockUser` mutation. +""" +enum LockUserStatus { + """ + The user was locked. + """ + LOCKED + """ + The user was not found. + """ + NOT_FOUND +} + +type MatrixUser { + """ + The Matrix ID of the user. + """ + mxid: String! + """ + The display name of the user, if any. + """ + displayName: String + """ + The avatar URL of the user, if any. + """ + avatarUrl: String + """ + Whether the user is deactivated on the homeserver. + """ + deactivated: Boolean! +} + +""" +The mutations root of the GraphQL interface. +""" +type Mutation { + """ + Add an email address to the specified user + """ + addEmail(input: AddEmailInput!): AddEmailPayload! + @deprecated(reason: "Use `startEmailAuthentication` instead.") + """ + Remove an email address + """ + removeEmail(input: RemoveEmailInput!): RemoveEmailPayload! + """ + Set an email address as primary + """ + setPrimaryEmail(input: SetPrimaryEmailInput!): SetPrimaryEmailPayload! + @deprecated( + reason: "This doesn't do anything anymore, but is kept to avoid breaking existing queries" + ) + """ + Start a new email authentication flow + """ + startEmailAuthentication( + input: StartEmailAuthenticationInput! + ): StartEmailAuthenticationPayload! + """ + Resend the email authentication code + """ + resendEmailAuthenticationCode( + input: ResendEmailAuthenticationCodeInput! + ): ResendEmailAuthenticationCodePayload! + """ + Complete the email authentication flow + """ + completeEmailAuthentication( + input: CompleteEmailAuthenticationInput! + ): CompleteEmailAuthenticationPayload! + """ + Add a user. This is only available to administrators. + """ + addUser(input: AddUserInput!): AddUserPayload! + """ + Lock a user. This is only available to administrators. + """ + lockUser(input: LockUserInput!): LockUserPayload! + """ + Unlock and reactivate a user. This is only available to administrators. + """ + unlockUser(input: UnlockUserInput!): UnlockUserPayload! + """ + Set whether a user can request admin. This is only available to + administrators. + """ + setCanRequestAdmin( + input: SetCanRequestAdminInput! + ): SetCanRequestAdminPayload! + """ + Temporarily allow user to reset their cross-signing keys. + """ + allowUserCrossSigningReset( + input: AllowUserCrossSigningResetInput! + ): AllowUserCrossSigningResetPayload! + """ + Set the password for a user. + + This can be used by server administrators to set any user's password, + or, provided the capability hasn't been disabled on this server, + by a user to change their own password as long as they know their + current password. + """ + setPassword(input: SetPasswordInput!): SetPasswordPayload! + """ + Set the password for yourself, using a recovery ticket sent by e-mail. + """ + setPasswordByRecovery(input: SetPasswordByRecoveryInput!): SetPasswordPayload! + """ + Resend a user recovery email + + This is used when a user opens a recovery link that has expired. In this + case, we display a link for them to get a new recovery email, which + calls this mutation. + """ + resendRecoveryEmail( + input: ResendRecoveryEmailInput! + ): ResendRecoveryEmailPayload! + """ + Deactivate the current user account + + If the user has a password, it *must* be supplied in the `password` + field. + """ + deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload! + """ + Create a new arbitrary OAuth 2.0 Session. + + Only available for administrators. + """ + createOauth2Session( + input: CreateOAuth2SessionInput! + ): CreateOAuth2SessionPayload! + endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! + setOauth2SessionName( + input: SetOAuth2SessionNameInput! + ): SetOAuth2SessionNamePayload! + endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! + setCompatSessionName( + input: SetCompatSessionNameInput! + ): SetCompatSessionNamePayload! + endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! + """ + Set the display name of a user + """ + setDisplayName(input: SetDisplayNameInput!): SetDisplayNamePayload! +} + +""" +An object with an ID. +""" +interface Node { + """ + ID of the object. + """ + id: ID! +} + +""" +The application type advertised by the client. +""" +enum Oauth2ApplicationType { + """ + Client is a web application. + """ + WEB + """ + Client is a native application. + """ + NATIVE +} + +""" +An OAuth 2.0 client +""" +type Oauth2Client implements Node { + """ + ID of the object. + """ + id: ID! + """ + OAuth 2.0 client ID + """ + clientId: String! + """ + Client name advertised by the client. + """ + clientName: String + """ + Client URI advertised by the client. + """ + clientUri: Url + """ + Logo URI advertised by the client. + """ + logoUri: Url + """ + Terms of services URI advertised by the client. + """ + tosUri: Url + """ + Privacy policy URI advertised by the client. + """ + policyUri: Url + """ + List of redirect URIs used for authorization grants by the client. + """ + redirectUris: [Url!]! + """ + The application type advertised by the client. + """ + applicationType: Oauth2ApplicationType +} + +""" +An OAuth 2.0 session represents a client session which used the OAuth APIs +to login. +""" +type Oauth2Session implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + OAuth 2.0 client used by this session. + """ + client: Oauth2Client! + """ + Scope granted for this session. + """ + scope: String! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the session ended. + """ + finishedAt: DateTime + """ + The user-agent with which the session was created. + """ + userAgent: UserAgent + """ + The state of the session. + """ + state: SessionState! + """ + The browser session which started this OAuth 2.0 session. + """ + browserSession: BrowserSession + """ + User authorized for this session. + """ + user: User + """ + The last IP address used by the session. + """ + lastActiveIp: String + """ + The last time the session was active. + """ + lastActiveAt: DateTime + """ + The user-provided name for this session. + """ + humanName: String +} + +type Oauth2SessionConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [Oauth2SessionEdge!]! + """ + A list of nodes. + """ + nodes: [Oauth2Session!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type Oauth2SessionEdge { + """ + The item at the end of the edge + """ + node: Oauth2Session! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +Information about pagination in a connection +""" +type PageInfo { + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String +} + +""" +The query root of the GraphQL interface. +""" +type Query { + """ + Get the current logged in browser session + """ + currentBrowserSession: BrowserSession + @deprecated(reason: "Use `viewerSession` instead.") + """ + Get the current logged in user + """ + currentUser: User @deprecated(reason: "Use `viewer` instead.") + """ + Fetch an OAuth 2.0 client by its ID. + """ + oauth2Client(id: ID!): Oauth2Client + """ + Fetch a browser session by its ID. + """ + browserSession(id: ID!): BrowserSession + """ + Fetch a compatible session by its ID. + """ + compatSession(id: ID!): CompatSession + """ + Fetch an OAuth 2.0 session by its ID. + """ + oauth2Session(id: ID!): Oauth2Session + """ + Fetch a user email by its ID. + """ + userEmail(id: ID!): UserEmail + """ + Fetch a user recovery ticket. + """ + userRecoveryTicket(ticket: String!): UserRecoveryTicket + """ + Fetch a user email authentication session + """ + userEmailAuthentication(id: ID!): UserEmailAuthentication + """ + Fetches an object given its ID. + """ + node(id: ID!): Node + """ + Get the current site configuration + """ + siteConfig: SiteConfig! + """ + Fetch a user by its ID. + """ + user(id: ID!): User + """ + Fetch a user by its username. + """ + userByUsername(username: String!): User + """ + Get a list of users. + + This is only available to administrators. + """ + users( + """ + List only users with the given state. + """ + state: UserState + """ + List only users with the given 'canRequestAdmin' value + """ + canRequestAdmin: Boolean + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UserConnection! + """ + Fetch an upstream OAuth 2.0 link by its ID. + """ + upstreamOauth2Link(id: ID!): UpstreamOAuth2Link + """ + Fetch an upstream OAuth 2.0 provider by its ID. + """ + upstreamOauth2Provider(id: ID!): UpstreamOAuth2Provider + """ + Get a list of upstream OAuth 2.0 providers. + """ + upstreamOauth2Providers( + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UpstreamOAuth2ProviderConnection! + """ + Lookup a compat or OAuth 2.0 session + """ + session(userId: ID!, deviceId: String!): Session + """ + Get the viewer + """ + viewer: Viewer! + """ + Get the viewer's session + """ + viewerSession: ViewerSession! +} + +""" +The input for the `removeEmail` mutation +""" +input RemoveEmailInput { + """ + The ID of the email address to remove + """ + userEmailId: ID! + """ + The user's current password. This is required if the user is not an + admin and it has a password on its account. + """ + password: String +} + +""" +The payload of the `removeEmail` mutation +""" +type RemoveEmailPayload { + """ + Status of the operation + """ + status: RemoveEmailStatus! + """ + The email address that was removed + """ + email: UserEmail + """ + The user to whom the email address belonged + """ + user: User +} + +""" +The status of the `removeEmail` mutation +""" +enum RemoveEmailStatus { + """ + The email address was removed + """ + REMOVED + """ + The email address was not found + """ + NOT_FOUND + """ + The password provided is incorrect + """ + INCORRECT_PASSWORD +} + +""" +The input for the `resendEmailAuthenticationCode` mutation +""" +input ResendEmailAuthenticationCodeInput { + """ + The ID of the authentication session to resend the code for + """ + id: ID! + """ + The language to use for the email + """ + language: String! = "en" +} + +""" +The payload of the `resendEmailAuthenticationCode` mutation +""" +type ResendEmailAuthenticationCodePayload { + """ + Status of the operation + """ + status: ResendEmailAuthenticationCodeStatus! +} + +""" +The status of the `resendEmailAuthenticationCode` mutation +""" +enum ResendEmailAuthenticationCodeStatus { + """ + The email was resent + """ + RESENT + """ + The email authentication session is already completed + """ + COMPLETED + """ + Too many attempts to resend an email authentication code + """ + RATE_LIMITED +} + +""" +The input for the `resendRecoveryEmail` mutation. +""" +input ResendRecoveryEmailInput { + """ + The recovery ticket to use. + """ + ticket: String! +} + +""" +The return type for the `resendRecoveryEmail` mutation. +""" +type ResendRecoveryEmailPayload { + """ + Status of the operation + """ + status: ResendRecoveryEmailStatus! + """ + URL to continue the recovery process + """ + progressUrl: Url +} + +""" +The status of the `resendRecoveryEmail` mutation. +""" +enum ResendRecoveryEmailStatus { + """ + The recovery ticket was not found. + """ + NO_SUCH_RECOVERY_TICKET + """ + The rate limit was exceeded. + """ + RATE_LIMITED + """ + The recovery email was sent. + """ + SENT +} + +""" +A client session, either compat or OAuth 2.0 +""" +union Session = CompatSession | Oauth2Session + +""" +The state of a session +""" +enum SessionState { + """ + The session is active. + """ + ACTIVE + """ + The session is no longer active. + """ + FINISHED +} + +""" +The input for the `setCanRequestAdmin` mutation. +""" +input SetCanRequestAdminInput { + """ + The ID of the user to update. + """ + userId: ID! + """ + Whether the user can request admin. + """ + canRequestAdmin: Boolean! +} + +""" +The payload for the `setCanRequestAdmin` mutation. +""" +type SetCanRequestAdminPayload { + """ + The user that was updated. + """ + user: User +} + +""" +The input of the `setCompatSessionName` mutation. +""" +input SetCompatSessionNameInput { + """ + The ID of the session to set the name of. + """ + compatSessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetCompatSessionNamePayload { + """ + The status of the mutation. + """ + status: SetCompatSessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: CompatSession +} + +""" +The status of the `setCompatSessionName` mutation. +""" +enum SetCompatSessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + +""" +The input for the `addEmail` mutation +""" +input SetDisplayNameInput { + """ + The ID of the user to add the email address to + """ + userId: ID! + """ + The display name to set. If `None`, the display name will be removed. + """ + displayName: String +} + +""" +The payload of the `setDisplayName` mutation +""" +type SetDisplayNamePayload { + """ + Status of the operation + """ + status: SetDisplayNameStatus! + """ + The user that was updated + """ + user: User +} + +""" +The status of the `setDisplayName` mutation +""" +enum SetDisplayNameStatus { + """ + The display name was set + """ + SET + """ + The display name is invalid + """ + INVALID +} + +""" +The input of the `setOauth2SessionName` mutation. +""" +input SetOAuth2SessionNameInput { + """ + The ID of the session to set the name of. + """ + oauth2SessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetOAuth2SessionNamePayload { + """ + The status of the mutation. + """ + status: SetOAuth2SessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: Oauth2Session +} + +""" +The status of the `setOauth2SessionName` mutation. +""" +enum SetOAuth2SessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + +""" +The input for the `setPasswordByRecovery` mutation. +""" +input SetPasswordByRecoveryInput { + """ + The recovery ticket to use. + This identifies the user as well as proving authorisation to perform the + recovery operation. + """ + ticket: String! + """ + The new password for the user. + """ + newPassword: String! +} + +""" +The input for the `setPassword` mutation. +""" +input SetPasswordInput { + """ + The ID of the user to set the password for. + If you are not a server administrator then this must be your own user + ID. + """ + userId: ID! + """ + The current password of the user. + Required if you are not a server administrator. + """ + currentPassword: String + """ + The new password for the user. + """ + newPassword: String! +} + +""" +The return type for the `setPassword` mutation. +""" +type SetPasswordPayload { + """ + Status of the operation + """ + status: SetPasswordStatus! +} + +""" +The status of the `setPassword` mutation. +""" +enum SetPasswordStatus { + """ + The password was updated. + """ + ALLOWED + """ + The user was not found. + """ + NOT_FOUND + """ + The user doesn't have a current password to attempt to match against. + """ + NO_CURRENT_PASSWORD + """ + The supplied current password was wrong. + """ + WRONG_PASSWORD + """ + The new password is invalid. For example, it may not meet configured + security requirements. + """ + INVALID_NEW_PASSWORD + """ + You aren't allowed to set the password for that user. + This happens if you aren't setting your own password and you aren't a + server administrator. + """ + NOT_ALLOWED + """ + Password support has been disabled. + This usually means that login is handled by an upstream identity + provider. + """ + PASSWORD_CHANGES_DISABLED + """ + The specified recovery ticket does not exist. + """ + NO_SUCH_RECOVERY_TICKET + """ + The specified recovery ticket has already been used and cannot be used + again. + """ + RECOVERY_TICKET_ALREADY_USED + """ + The specified recovery ticket has expired. + """ + EXPIRED_RECOVERY_TICKET + """ + Your account is locked and you can't change its password. + """ + ACCOUNT_LOCKED +} + +""" +The input for the `setPrimaryEmail` mutation +""" +input SetPrimaryEmailInput { + """ + The ID of the email address to set as primary + """ + userEmailId: ID! +} + +""" +The payload of the `setPrimaryEmail` mutation +""" +type SetPrimaryEmailPayload { + status: SetPrimaryEmailStatus! + """ + The user to whom the email address belongs + """ + user: User +} + +""" +The status of the `setPrimaryEmail` mutation +""" +enum SetPrimaryEmailStatus { + """ + The email address was set as primary + """ + SET + """ + The email address was not found + """ + NOT_FOUND + """ + Can't make an unverified email address primary + """ + UNVERIFIED +} + +type SiteConfig implements Node { + """ + The configuration of CAPTCHA provider. + """ + captchaConfig: CaptchaConfig + """ + The server name of the homeserver. + """ + serverName: String! + """ + The URL to the privacy policy. + """ + policyUri: Url + """ + The URL to the terms of service. + """ + tosUri: Url + """ + Imprint to show in the footer. + """ + imprint: String + """ + Whether users can change their email. + """ + emailChangeAllowed: Boolean! + """ + Whether users can change their display name. + """ + displayNameChangeAllowed: Boolean! + """ + Whether passwords are enabled for login. + """ + passwordLoginEnabled: Boolean! + """ + Whether passwords are enabled and users can change their own passwords. + """ + passwordChangeAllowed: Boolean! + """ + Whether passwords are enabled and users can register using a password. + """ + passwordRegistrationEnabled: Boolean! + """ + Whether users can delete their own account. + """ + accountDeactivationAllowed: Boolean! + """ + Minimum password complexity, from 0 to 4, in terms of a zxcvbn score. + The exact scorer (including dictionaries and other data tables) + in use is . + """ + minimumPasswordComplexity: Int! + """ + Whether users can log in with their email address. + """ + loginWithEmailAllowed: Boolean! + """ + Experimental plan management iframe URI. + """ + planManagementIframeUri: String + """ + The ID of the site configuration. + """ + id: ID! +} + +""" +The input for the `startEmailAuthentication` mutation +""" +input StartEmailAuthenticationInput { + """ + The email address to add to the account + """ + email: String! + """ + The user's current password. This is required if the user has a password + on its account. + """ + password: String + """ + The language to use for the email + """ + language: String! = "en" +} + +""" +The payload of the `startEmailAuthentication` mutation +""" +type StartEmailAuthenticationPayload { + """ + Status of the operation + """ + status: StartEmailAuthenticationStatus! + """ + The email authentication session that was started + """ + authentication: UserEmailAuthentication + """ + The list of policy violations if the email address was denied + """ + violations: [String!] +} + +""" +The status of the `startEmailAuthentication` mutation +""" +enum StartEmailAuthenticationStatus { + """ + The email address was started + """ + STARTED + """ + The email address is invalid + """ + INVALID_EMAIL_ADDRESS + """ + Too many attempts to start an email authentication + """ + RATE_LIMITED + """ + The email address isn't allowed by the policy + """ + DENIED + """ + The email address is already in use on this account + """ + IN_USE + """ + The password provided is incorrect + """ + INCORRECT_PASSWORD +} + +""" +The input for the `unlockUser` mutation. +""" +input UnlockUserInput { + """ + The ID of the user to unlock + """ + userId: ID! +} + +""" +The payload for the `unlockUser` mutation. +""" +type UnlockUserPayload { + """ + Status of the operation + """ + status: UnlockUserStatus! + """ + The user that was unlocked. + """ + user: User +} + +""" +The status of the `unlockUser` mutation. +""" +enum UnlockUserStatus { + """ + The user was unlocked. + """ + UNLOCKED + """ + The user was not found. + """ + NOT_FOUND +} + +type UpstreamOAuth2Link implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + Subject used for linking + """ + subject: String! + """ + A human-readable name for the link subject. + """ + humanAccountName: String + """ + The provider for which this link is. + """ + provider: UpstreamOAuth2Provider! + """ + The user to which this link is associated. + """ + user: User +} + +type UpstreamOAuth2LinkConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UpstreamOAuth2LinkEdge!]! + """ + A list of nodes. + """ + nodes: [UpstreamOAuth2Link!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UpstreamOAuth2LinkEdge { + """ + The item at the end of the edge + """ + node: UpstreamOAuth2Link! + """ + A cursor for use in pagination + """ + cursor: String! +} + +type UpstreamOAuth2Provider implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + OpenID Connect issuer URL. + """ + issuer: String + """ + Client ID used for this provider. + """ + clientId: String! + """ + A human-readable name for this provider. + """ + humanName: String + """ + A brand identifier for this provider. + + One of `google`, `github`, `gitlab`, `apple` or `facebook`. + """ + brandName: String + """ + URL to start the linking process of the current user with this provider. + """ + linkUrl: Url! +} + +type UpstreamOAuth2ProviderConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UpstreamOAuth2ProviderEdge!]! + """ + A list of nodes. + """ + nodes: [UpstreamOAuth2Provider!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UpstreamOAuth2ProviderEdge { + """ + The item at the end of the edge + """ + node: UpstreamOAuth2Provider! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +URL is a String implementing the [URL Standard](http://url.spec.whatwg.org/) +""" +scalar Url + +""" +A user is an individual's account. +""" +type User implements Node { + """ + ID of the object. + """ + id: ID! + """ + Username chosen by the user. + """ + username: String! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the user was locked out. + """ + lockedAt: DateTime + """ + Whether the user can request admin privileges. + """ + canRequestAdmin: Boolean! + """ + Access to the user's Matrix account information. + """ + matrix: MatrixUser! + """ + Get the list of compatibility SSO logins, chronologically sorted + """ + compatSsoLogins( + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): CompatSsoLoginConnection! + """ + Get the list of compatibility sessions, chronologically sorted + """ + compatSessions( + """ + List only sessions with the given state. + """ + state: SessionState + """ + List only sessions with the given type. + """ + type: CompatSessionType + """ + List only sessions with a last active time is between the given bounds. + """ + lastActive: DateFilter + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): CompatSessionConnection! + """ + Get the list of active browser sessions, chronologically sorted + """ + browserSessions( + """ + List only sessions in the given state. + """ + state: SessionState + """ + List only sessions with a last active time is between the given bounds. + """ + lastActive: DateFilter + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): BrowserSessionConnection! + """ + Get the list of emails, chronologically sorted + """ + emails( + """ + List only emails in the given state. + """ + state: UserEmailState + @deprecated( + reason: "Emails are always confirmed, and have only one state" + ) + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UserEmailConnection! + """ + Get the list of OAuth 2.0 sessions, chronologically sorted + """ + oauth2Sessions( + """ + List only sessions in the given state. + """ + state: SessionState + """ + List only sessions for the given client. + """ + client: ID + """ + List only sessions with a last active time is between the given bounds. + """ + lastActive: DateFilter + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): Oauth2SessionConnection! + """ + Get the list of upstream OAuth 2.0 links + """ + upstreamOauth2Links( + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UpstreamOAuth2LinkConnection! + """ + Get the list of both compat and OAuth 2.0 sessions, chronologically + sorted + """ + appSessions( + """ + List only sessions in the given state. + """ + state: SessionState + """ + List only sessions for the given device. + """ + device: String + """ + List only sessions with a last active time is between the given bounds. + """ + lastActive: DateFilter + """ + List only sessions for the given session. + """ + browserSession: ID + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): AppSessionConnection! + """ + Check if the user has a password set. + """ + hasPassword: Boolean! +} + +""" +A parsed user agent string +""" +type UserAgent { + """ + The user agent string + """ + raw: String! + """ + The name of the browser + """ + name: String + """ + The version of the browser + """ + version: String + """ + The operating system name + """ + os: String + """ + The operating system version + """ + osVersion: String + """ + The device model + """ + model: String + """ + The device type + """ + deviceType: DeviceType! +} + +type UserConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UserEdge!]! + """ + A list of nodes. + """ + nodes: [User!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UserEdge { + """ + The item at the end of the edge + """ + node: User! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A user email address +""" +type UserEmail implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + Email address + """ + email: String! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the email address was confirmed. Is `null` if the email was never + verified by the user. + """ + confirmedAt: DateTime @deprecated(reason: "Emails are always confirmed now.") +} + +""" +A email authentication session +""" +type UserEmailAuthentication implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the object was last updated. + """ + completedAt: DateTime + """ + The email address associated with this session + """ + email: String! +} + +type UserEmailConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UserEmailEdge!]! + """ + A list of nodes. + """ + nodes: [UserEmail!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UserEmailEdge { + """ + The item at the end of the edge + """ + node: UserEmail! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +The state of a compatibility session. +""" +enum UserEmailState { + """ + The email address is pending confirmation. + """ + PENDING + """ + The email address has been confirmed. + """ + CONFIRMED +} + +""" +A recovery ticket +""" +type UserRecoveryTicket implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + The status of the ticket + """ + status: UserRecoveryTicketStatus! + """ + The username associated with this ticket + """ + username: String! + """ + The email address associated with this ticket + """ + email: String! +} + +""" +The status of a recovery ticket +""" +enum UserRecoveryTicketStatus { + """ + The ticket is valid + """ + VALID + """ + The ticket has expired + """ + EXPIRED + """ + The ticket has been consumed + """ + CONSUMED +} + +""" +The state of a user. +""" +enum UserState { + """ + The user is active. + """ + ACTIVE + """ + The user is locked. + """ + LOCKED +} + +""" +Represents the current viewer +""" +union Viewer = User | Anonymous + +""" +Represents the current viewer's session +""" +union ViewerSession = BrowserSession | Oauth2Session | Anonymous + +""" +Marks an element of a GraphQL schema as no longer supported. +""" +directive @deprecated( + reason: String = "No longer supported" +) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Provides a scalar specification URL for specifying the behavior of custom scalar types. +""" +directive @specifiedBy(url: String!) on SCALAR +schema { + query: Query + mutation: Mutation +} From dfdc2edf2d590a4bd8445c27293579524b71feb7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 15:25:01 -0500 Subject: [PATCH 12/70] Add potential `repo.compat_session().finish_bulk(...)` plans --- crates/handlers/src/compat/login.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 2c4af6930..5c0c9a2e6 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -768,6 +768,10 @@ async fn user_password_login( // In the future, it may be nice to avoid sessions with // cryptographic state (what does that mean exactly? keys uploaded // for device?). + // + // FIXME: We could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the ability to + // limit and order. let compat = repo .compat_session() .list( @@ -785,8 +789,7 @@ async fn user_password_login( let mut sessions_removed = 0; for edge in compat.edges { let (compat_session, _) = edge.node; - let _compat_session = - repo.compat_session().finish(clock, compat_session).await?; + repo.compat_session().finish(clock, compat_session).await?; sessions_removed += 1; } sessions_removed From 611c684e21a828a054264441c10225649dcd59c8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 16:08:02 -0500 Subject: [PATCH 13/70] Better error --- crates/handlers/src/compat/login.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5c0c9a2e6..de0e597fd 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -777,10 +777,14 @@ async fn user_password_login( .list( // TODO: Order by `last_active_at` CompatSessionFilter::new().for_user(&user).active_only(), - Pagination::first( - usize::try_from(need_to_remove) - .map_err(|err| RouteError::Internal(err.into()))?, - ), + Pagination::first(usize::try_from(need_to_remove).map_err(|err| { + RouteError::Internal( + anyhow::anyhow!( + "Unable to convert `need_to_remove` to usize: {err}" + ) + .into(), + ) + })?), ) .await?; From 9d7e9413449210fb5b8456cca858d87abe66770d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 16:25:45 -0500 Subject: [PATCH 14/70] Generic `process_violations_for_compat_login` --- crates/handlers/src/compat/login.rs | 203 +++++++++++++++------------- 1 file changed, 107 insertions(+), 96 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index de0e597fd..6ad360143 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -16,7 +16,7 @@ use mas_data_model::{ SiteConfig, TokenType, User, }; use mas_matrix::HomeserverConnection; -use mas_policy::{Policy, Requester, ViolationVariant, model::CompatLogin}; +use mas_policy::{Policy, Requester, Violation, ViolationVariant, model::CompatLogin}; use mas_storage::{ BoxRepository, BoxRepositoryFactory, Pagination, RepositoryAccess, compat::{ @@ -378,6 +378,7 @@ pub(crate) async fn post( ip_address: activity_tracker.ip(), user_agent: user_agent.clone(), }, + site_config.session_limit.as_ref(), &token, input.device_id, input.initial_device_display_name, @@ -491,12 +492,101 @@ pub(crate) async fn post( })) } +/// Given the violations from [`Policy::evaluate_compat_login`], return the appropriate `RouteError` response. +async fn process_violations_for_compat_login( + clock: &dyn Clock, + repo: &mut BoxRepository, + session_limit_config: Option<&SessionLimitConfig>, + user: &User, + violations: Vec, +) -> Result<(), RouteError> { + match (violations.len(), violations.first()) { + // If the only violation is having reached the session limit, we might be + // able to resolve the situation. + // + // We don't trigger this if there was some other violation anyway, since + // that means that removing a session wouldn't actually unblock the login. + (1, Some(violation)) if violation.variant == Some(ViolationVariant::TooManySessions) => { + let session_limit_config = session_limit_config + .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ + This is most likely a programming error."); + + // TODO: This should come from `ViolationVariant::TooManySessions` + let need_to_remove: u32 = 1; + let need_to_remove_usize = usize::try_from(need_to_remove).map_err(|err| { + RouteError::Internal( + anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}").into(), + ) + })?; + + // When logging in with the compatibility API, there is no way for us to + // display any web UI for people to remove devices, so we instead + // automatically remove their oldest devices (when `hard_limit_eviction` + // is configured). + if session_limit_config.hard_limit_eviction { + // Find the least recently used compat sessions + // + // In the future, it may be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys uploaded + // for device?). + // + // FIXME: We could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the ability to + // limit and order. + let compat_session_page = repo + .compat_session() + .list( + // TODO: Order by `last_active_at` + CompatSessionFilter::new().for_user(user).active_only(), + Pagination::first(need_to_remove_usize), + ) + .await?; + + // For now, we only automatically clean up compatibility sessions. + // If there aren't enough sessions that we could clean up, we just + // throw an error with an explanation. + if compat_session_page.edges.len() < need_to_remove_usize { + return Err(RouteError::PolicyHardSessionLimitReached); + } + + // Remove the sessions + let sessions_removed = { + let mut sessions_removed = 0; + for edge in compat_session_page.edges { + let (compat_session, _) = edge.node; + repo.compat_session().finish(clock, compat_session).await?; + sessions_removed += 1; + } + sessions_removed + }; + + // For now, we only automatically clean up compatibility sessions. + // If there are still too many sessions, we just throw an error with + // an explanation. + if sessions_removed < need_to_remove { + return Err(RouteError::PolicyHardSessionLimitReached); + } + } else { + // Tell the user about the limit + return Err(RouteError::PolicyHardSessionLimitReached); + } + } + // Just throw an error for any other violation + _ => { + return Err(RouteError::PolicyRejected); + } + } + + Ok(()) +} + async fn token_login( rng: &mut (dyn RngCore + Send), clock: &dyn Clock, repo: &mut BoxRepository, policy: &mut Policy, requester: Requester, + session_limit_config: Option<&SessionLimitConfig>, token: &str, requested_device_id: Option, initial_device_display_name: Option, @@ -599,21 +689,14 @@ async fn token_login( }) .await?; if !res.valid() { - // If the only violation is that we have too many sessions, then handle that - // separately. - // In the future, we intend to evict some sessions automatically instead. We - // don't trigger this if there was some other violation anyway, since that means - // that removing a session wouldn't actually unblock the login. - if res.violations.len() == 1 { - let violation = &res.violations[0]; - if violation.variant == Some(ViolationVariant::TooManySessions) { - // TODO - - // The only violation is having reached the session limit. - return Err(RouteError::PolicyHardSessionLimitReached); - } - } - return Err(RouteError::PolicyRejected); + process_violations_for_compat_login( + clock, + repo, + session_limit_config, + &browser_session.user, + res.violations, + ) + .await?; } // We first create the session in the database, commit the transaction, then @@ -735,86 +818,14 @@ async fn user_password_login( }) .await?; if !res.valid() { - match (res.violations.len(), res.violations.first()) { - // If the only violation is having reached the session limit, we might be - // able to resolve the situation. - // - // We don't trigger this if there was some other violation anyway, since - // that means that removing a session wouldn't actually unblock the login. - (1, Some(violation)) - if violation.variant == Some(ViolationVariant::TooManySessions) => - { - let session_limit_config = session_limit_config.as_ref() - .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ - This is most likely a programming error."); - - // TODO: This should come from `ViolationVariant::TooManySessions` - let need_to_remove: u32 = 1; - - // When logging in with the compatibility API, there is no way for us to - // display any web UI for people to remove devices, so we instead - // automatically remove their oldest devices (when `hard_limit_eviction` - // is configured). - if session_limit_config.hard_limit_eviction { - // For now, we only automatically clean up compatibility sessions. - // If there aren't enough sessions that we could clean up, we just - // throw an error with an explanation. - if session_counts.compat < need_to_remove.into() { - return Err(RouteError::PolicyHardSessionLimitReached); - } - - // Find the least recently used compat sessions - // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). - // - // FIXME: We could potentially use - // `repo.compat_session().finish_bulk(...)` if it had the ability to - // limit and order. - let compat = repo - .compat_session() - .list( - // TODO: Order by `last_active_at` - CompatSessionFilter::new().for_user(&user).active_only(), - Pagination::first(usize::try_from(need_to_remove).map_err(|err| { - RouteError::Internal( - anyhow::anyhow!( - "Unable to convert `need_to_remove` to usize: {err}" - ) - .into(), - ) - })?), - ) - .await?; - - // Remove the sessions - let sessions_removed = { - let mut sessions_removed = 0; - for edge in compat.edges { - let (compat_session, _) = edge.node; - repo.compat_session().finish(clock, compat_session).await?; - sessions_removed += 1; - } - sessions_removed - }; - - // For now, we only automatically clean up compatibility sessions. - // If there are still too many sessions, we just throw an error with - // an explanation. - if sessions_removed < need_to_remove { - return Err(RouteError::PolicyHardSessionLimitReached); - } - } else { - // Tell the user about the limit - return Err(RouteError::PolicyHardSessionLimitReached); - } - } - // Just throw an error for any other violation - _ => { - return Err(RouteError::PolicyRejected); - } - } + process_violations_for_compat_login( + clock, + repo, + session_limit_config, + &user, + res.violations, + ) + .await?; } let session = repo From 04ccd6def500241f7ee2b39e0b08ccb4c83f9172 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 16:27:13 -0500 Subject: [PATCH 15/70] `num_sessions_removed` --- crates/handlers/src/compat/login.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 6ad360143..9342db799 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -550,20 +550,20 @@ async fn process_violations_for_compat_login( } // Remove the sessions - let sessions_removed = { - let mut sessions_removed = 0; + let num_sessions_removed = { + let mut num_sessions_removed = 0; for edge in compat_session_page.edges { let (compat_session, _) = edge.node; repo.compat_session().finish(clock, compat_session).await?; - sessions_removed += 1; + num_sessions_removed += 1; } - sessions_removed + num_sessions_removed }; // For now, we only automatically clean up compatibility sessions. // If there are still too many sessions, we just throw an error with // an explanation. - if sessions_removed < need_to_remove { + if num_sessions_removed < need_to_remove { return Err(RouteError::PolicyHardSessionLimitReached); } } else { From 45971fa42fe5a2c851ab10a0b9360bbafdf0ae91 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 16:28:48 -0500 Subject: [PATCH 16/70] Remove `session_counts` reference change --- crates/handlers/src/compat/login.rs | 4 ++-- crates/handlers/src/compat/login_sso_complete.rs | 4 ++-- crates/policy/src/model.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 9342db799..90126b278 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -684,7 +684,7 @@ async fn token_login( user: &browser_session.user, login: CompatLogin::Token, session_replaced, - session_counts: &session_counts, + session_counts, requester, }) .await?; @@ -813,7 +813,7 @@ async fn user_password_login( user: &user, login: CompatLogin::Password, session_replaced, - session_counts: &session_counts, + session_counts, requester: policy_requester, }) .await?; diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 0e93edafd..df059cd36 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -123,7 +123,7 @@ pub async fn get( // We don't know if there's going to be a replacement until we received the device ID, // which happens too late. session_replaced: false, - session_counts: &session_counts, + session_counts, requester: mas_policy::Requester { ip_address: activity_tracker.ip(), user_agent, @@ -268,7 +268,7 @@ pub async fn post( login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, - session_counts: &session_counts, + session_counts, // We don't know if there's going to be a replacement until we received the device ID, // which happens too late. session_replaced: false, diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 68ef49195..a3bf24b5f 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -202,7 +202,7 @@ pub struct CompatLoginInput<'a> { pub user: &'a User, /// How many sessions the user has. - pub session_counts: &'a SessionCounts, + pub session_counts: SessionCounts, /// Whether a session will be replaced by this login pub session_replaced: bool, From fc3bba48e7221a1ac6c6f1937e507cdf888581f4 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 16:36:10 -0500 Subject: [PATCH 17/70] Remove nested valid check --- crates/handlers/src/compat/login.rs | 30 +++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 90126b278..ec9ff39d0 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -571,6 +571,8 @@ async fn process_violations_for_compat_login( return Err(RouteError::PolicyHardSessionLimitReached); } } + // Nothing is wrong + (0, None) => return Ok(()), // Just throw an error for any other violation _ => { return Err(RouteError::PolicyRejected); @@ -688,16 +690,14 @@ async fn token_login( requester, }) .await?; - if !res.valid() { - process_violations_for_compat_login( - clock, - repo, - session_limit_config, - &browser_session.user, - res.violations, - ) - .await?; - } + process_violations_for_compat_login( + clock, + repo, + session_limit_config, + &browser_session.user, + res.violations, + ) + .await?; // We first create the session in the database, commit the transaction, then // create it on the homeserver, scheduling a device sync job afterwards to @@ -817,16 +817,8 @@ async fn user_password_login( requester: policy_requester, }) .await?; - if !res.valid() { - process_violations_for_compat_login( - clock, - repo, - session_limit_config, - &user, - res.violations, - ) + process_violations_for_compat_login(clock, repo, session_limit_config, &user, res.violations) .await?; - } let session = repo .compat_session() From c77afd5243b7c18932e335848d9bb9d7ec30d4ea Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 17:45:26 -0500 Subject: [PATCH 18/70] Add some tests --- crates/handlers/src/compat/login.rs | 120 ++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index ec9ff39d0..39dcbaf17 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -838,6 +838,8 @@ async fn user_password_login( #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use hyper::Request; use mas_matrix::{HomeserverConnection, ProvisionRequest}; use rand::distributions::{Alphanumeric, DistString}; @@ -1586,4 +1588,122 @@ mod tests { token } + + /// Test that the `soft_limit` is not enforced for compat login. + /// + /// `soft_limit` is for when we allow the user to remove devices in interactive + /// contexts. With the compatibility login API, there is no opportunity for us to + /// present a web UI. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_soft_limit_does_not_affect_compat_login(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + session_limit: Some(SessionLimitConfig { + // Lowest non-zero value so we don't have to login a bunch (lower + // than `hard_limit`) + soft_limit: NonZeroU64::new(1).unwrap(), + // Some arbitrary high value (more than we login) + hard_limit: NonZeroU64::new(5).unwrap(), + hard_limit_eviction: false, + }), + ..test_site_config() + }, + ) + .await + .unwrap(); + + let session_limit_config = state + .site_config + .session_limit + .as_ref() + .expect("Expected `session_limit` configured for this test"); + + assert!( + session_limit_config.soft_limit < session_limit_config.hard_limit, + "`soft_limit` should be lower than the `hard_limit` so we don't run into `hard_limit` \ + (we're testing the `soft_limit`)", + ); + + let _user = user_with_password(&state, "alice", "password", false).await; + + // Keep logging in to add more sessions, more than the `soft_limit` + #[allow(clippy::range_plus_one)] + for _ in 0..(session_limit_config.soft_limit.get() + 1) { + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + } + } + + /// Test that the `hard_limit` prevents more sessions + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_hard_limit_compat_login(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + session_limit: Some(SessionLimitConfig { + // Lowest non-zero value so we don't have to login a bunch (lower + // than `hard_limit`) + soft_limit: NonZeroU64::new(1).unwrap(), + hard_limit: NonZeroU64::new(2).unwrap(), + hard_limit_eviction: false, + }), + ..test_site_config() + }, + ) + .await + .unwrap(); + + let session_limit_config = state + .site_config + .session_limit + .as_ref() + .expect("Expected `session_limit` configured for this test"); + + let _user = user_with_password(&state, "alice", "password", false).await; + + // Keep logging in to add more sessions, up to the `hard_limit` + #[allow(clippy::range_plus_one)] + for _ in 0..session_limit_config.hard_limit.get() { + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + } + + // One more login will tip us over the `hard_limit` + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert!( + body.get("errcode") + .expect("Expected errror response to include an `errcode`") + == "M_FORBIDDEN", + "Expected `errcode` to be `M_FORBIDDEN`" + ); + } } From 724e0cf5ca59cff5e59166bdb435af727ddf7cf4 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 18:01:39 -0500 Subject: [PATCH 19/70] Pass in `session_limit_config` directly to policy Revert changes from https://github.com/element-hq/matrix-authentication-service/pull/5221. I assume it was done that way as the "session_limit_config" doesn't change after the server is created. But this makes downstream usage complicated as you whenever you create `SiteConfig`, you also have to make sure to configure whatever else is necessary. Easier to just pass in `session_limit_config` as necessary whenever we evaluate the policy --- crates/cli/src/commands/debug.rs | 9 ++----- crates/cli/src/commands/server.rs | 4 +-- crates/cli/src/util.rs | 15 ++--------- crates/handlers/src/compat/login.rs | 2 ++ .../handlers/src/compat/login_sso_complete.rs | 10 ++++++- .../src/oauth2/authorization/consent.rs | 9 ++++++- crates/handlers/src/oauth2/device/consent.rs | 8 +++++- crates/handlers/src/oauth2/token.rs | 3 +++ crates/handlers/src/test_utils.rs | 2 +- crates/policy/src/lib.rs | 18 +++++-------- crates/policy/src/model.rs | 10 ++++++- .../authorization_grant.rego | 4 +-- .../authorization_grant_test.rego | 14 +++++----- policies/compat_login/compat_login.rego | 8 +++--- policies/compat_login/compat_login_test.rego | 26 +++++++++---------- 15 files changed, 76 insertions(+), 66 deletions(-) diff --git a/crates/cli/src/commands/debug.rs b/crates/cli/src/commands/debug.rs index 6da64f95b..bb87c5e81 100644 --- a/crates/cli/src/commands/debug.rs +++ b/crates/cli/src/commands/debug.rs @@ -9,8 +9,7 @@ use std::process::ExitCode; use clap::Parser; use figment::Figment; use mas_config::{ - ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, ExperimentalConfig, - MatrixConfig, PolicyConfig, + ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PolicyConfig, }; use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; @@ -46,12 +45,8 @@ impl Options { PolicyConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; let matrix_config = MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; - let experimental_config = - ExperimentalConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!("Loading and compiling the policy module"); - let policy_factory = - policy_factory_from_config(&config, &matrix_config, &experimental_config) - .await?; + let policy_factory = policy_factory_from_config(&config, &matrix_config).await?; if with_dynamic_data { let database_config = diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b72d48111..12f9e8a14 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -127,9 +127,7 @@ impl Options { // Load and compile the WASM policies (and fallback to the default embedded one) info!("Loading and compiling the policy module"); - let policy_factory = - policy_factory_from_config(&config.policy, &config.matrix, &config.experimental) - .await?; + let policy_factory = policy_factory_from_config(&config.policy, &config.matrix).await?; let policy_factory = Arc::new(policy_factory); load_policy_factory_dynamic_data_continuously( diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index caa76664d..8f5989305 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -135,7 +135,6 @@ pub fn test_mailer_in_background(mailer: &Mailer, timeout: Duration) { pub async fn policy_factory_from_config( config: &PolicyConfig, matrix_config: &MatrixConfig, - experimental_config: &ExperimentalConfig, ) -> Result { let policy_file = tokio::fs::File::open(&config.wasm_module) .await @@ -149,18 +148,8 @@ pub async fn policy_factory_from_config( email: config.email_entrypoint.clone(), }; - let session_limit_config = - experimental_config - .session_limit - .as_ref() - .map(|c| SessionLimitConfig { - soft_limit: c.soft_limit, - hard_limit: c.hard_limit, - hard_limit_eviction: c.hard_limit_eviction, - }); - - let data = mas_policy::Data::new(matrix_config.homeserver.clone(), session_limit_config) - .with_rest(config.data.clone()); + let data = + mas_policy::Data::new(matrix_config.homeserver.clone()).with_rest(config.data.clone()); PolicyFactory::load(policy_file, data, entrypoints) .await diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 39dcbaf17..1bfdd34d6 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -684,6 +684,7 @@ async fn token_login( let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &browser_session.user, + session_limit: session_limit_config, login: CompatLogin::Token, session_replaced, session_counts, @@ -811,6 +812,7 @@ async fn user_password_login( let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &user, + session_limit: session_limit_config, login: CompatLogin::Password, session_replaced, session_counts, diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index df059cd36..1a1dbcdc5 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -19,7 +19,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, Clock, MatrixUser}; +use mas_data_model::{BoxClock, BoxRng, Clock, MatrixUser, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::{Policy, model::CompatLogin}; use mas_router::{CompatLoginSsoAction, UrlBuilder}; @@ -53,6 +53,7 @@ pub async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, mut policy: Policy, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -114,9 +115,12 @@ pub async fn get( // We can close the repository early, we don't need it at this point repo.save().await?; + let session_limit_config = site_config.session_limit.as_ref(); + let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &session.user, + session_limit: session_limit_config, login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, @@ -193,6 +197,7 @@ pub async fn post( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(url_builder): State, + State(site_config): State, mut policy: Policy, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -262,9 +267,12 @@ pub async fn post( let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; + let session_limit_config = site_config.session_limit.as_ref(); + let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &session.user, + session_limit: session_limit_config, login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index ab51bef1c..31fdb8b3e 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -17,7 +17,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser}; +use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser, SiteConfig}; use mas_keystore::Keystore; use mas_matrix::HomeserverConnection; use mas_policy::Policy; @@ -91,6 +91,7 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -145,9 +146,12 @@ pub(crate) async fn get( // We can close the repository early, we don't need it at this point repo.save().await?; + let session_limit_config = site_config.session_limit.as_ref(); + let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&session.user), + session_limit: session_limit_config, client: &client, session_counts: Some(session_counts), scope: &grant.scope, @@ -220,6 +224,7 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(key_store): State, + State(site_config): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -275,11 +280,13 @@ pub(crate) async fn post( return Err(RouteError::GrantNotPending(grant.id)); } + let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user).await?; let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&browser_session.user), + session_limit: session_limit_config, client: &client, session_counts: Some(session_counts), scope: &grant.scope, diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 3912d2dc1..b39a0e159 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -18,7 +18,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, MatrixUser}; +use mas_data_model::{BoxClock, BoxRng, MatrixUser, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; @@ -53,6 +53,7 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, mut repo: BoxRepository, mut policy: Policy, activity_tracker: BoundActivityTracker, @@ -107,6 +108,7 @@ pub(crate) async fn get( .context("Client not found") .map_err(InternalError::from_anyhow)?; + let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // We can close the repository early, we don't need it at this point @@ -117,6 +119,7 @@ pub(crate) async fn get( .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, + session_limit: session_limit_config, session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), @@ -191,6 +194,7 @@ pub(crate) async fn post( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, mut repo: BoxRepository, mut policy: Policy, activity_tracker: BoundActivityTracker, @@ -246,6 +250,7 @@ pub(crate) async fn post( .context("Client not found") .map_err(InternalError::from_anyhow)?; + let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // Evaluate the policy @@ -253,6 +258,7 @@ pub(crate) async fn post( .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, + session_limit: session_limit_config, session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 696c7d426..d19ca9c5e 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -792,10 +792,13 @@ async fn client_credentials_grant( .clone() .unwrap_or_else(|| std::iter::empty::().collect()); + let session_limit_config = site_config.session_limit.as_ref(); + // Make the request go through the policy engine let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: None, + session_limit: session_limit_config, client, session_counts: None, scope: &scope, diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 521a4848d..a2bbf7d4a 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -86,7 +86,7 @@ pub(crate) async fn policy_factory( email: "email/violation".to_owned(), }; - let data = mas_policy::Data::new(server_name.to_owned(), None).with_rest(data); + let data = mas_policy::Data::new(server_name.to_owned()).with_rest(data); let policy_factory = PolicyFactory::load(file, data, entrypoints).await?; let policy_factory = Arc::new(policy_factory); diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index a5d4805ad..af715909b 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -9,7 +9,7 @@ pub mod model; use std::sync::Arc; use arc_swap::ArcSwap; -use mas_data_model::{SessionLimitConfig, Ulid}; +use mas_data_model::Ulid; use opa_wasm::{ Runtime, wasmtime::{Config, Engine, Module, OptLevel, Store}, @@ -100,19 +100,13 @@ pub struct Data { #[derive(Serialize, Debug)] struct BaseData { server_name: String, - - /// Limits on the number of application sessions that each user can have - session_limit: Option, } impl Data { #[must_use] - pub fn new(server_name: String, session_limit: Option) -> Self { + pub fn new(server_name: String) -> Self { Self { - base: BaseData { - server_name, - session_limit, - }, + base: BaseData { server_name }, rest: None, } @@ -507,7 +501,7 @@ mod tests { #[tokio::test] async fn test_register() { - let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ + let data = Data::new("example.com".to_owned()).with_rest(serde_json::json!({ "allowed_domains": ["element.io", "*.element.io"], "banned_domains": ["staging.element.io"], })); @@ -572,7 +566,7 @@ mod tests { #[tokio::test] async fn test_dynamic_data() { - let data = Data::new("example.com".to_owned(), None); + let data = Data::new("example.com".to_owned()); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -636,7 +630,7 @@ mod tests { #[tokio::test] async fn test_big_dynamic_data() { - let data = Data::new("example.com".to_owned(), None); + let data = Data::new("example.com".to_owned()); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index a3bf24b5f..b8be99bae 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -11,7 +11,7 @@ use std::net::IpAddr; -use mas_data_model::{Client, User}; +use mas_data_model::{Client, SessionLimitConfig, User}; use oauth2_types::{registration::VerifiedClientMetadata, scope::Scope}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -179,6 +179,10 @@ pub struct AuthorizationGrantInput<'a> { #[schemars(with = "Option>")] pub user: Option<&'a User>, + /// Limits on the number of application sessions that each user can have + #[schemars(with = "std::collections::HashMap")] + pub session_limit: Option<&'a SessionLimitConfig>, + /// How many sessions the user has. /// Not populated if it's not a user logging in. pub session_counts: Option, @@ -201,6 +205,10 @@ pub struct CompatLoginInput<'a> { #[schemars(with = "std::collections::HashMap")] pub user: &'a User, + /// Limits on the number of application sessions that each user can have + #[schemars(with = "std::collections::HashMap")] + pub session_limit: Option<&'a SessionLimitConfig>, + /// How many sessions the user has. pub session_counts: SessionCounts, diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index e7d1e68e5..d7631a538 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -159,7 +159,7 @@ violation contains { "msg": "user has too many active sessions", } if { # Only apply if session limits are enabled in the config - data.session_limit != null + input.session_limit != null # Only apply if it's a user logging in (who therefore has countable sessions) input.session_counts != null @@ -168,5 +168,5 @@ violation contains { # reached or exceeded. # We use the soft limit because the user will be able to interactively remove # sessions to return under the limit. - data.session_limit.soft_limit <= input.session_counts.total + input.session_limit.soft_limit <= input.session_counts.total } diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index e2ca74086..a0752367e 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -226,31 +226,31 @@ test_mas_scopes if { test_session_limiting if { authorization_grant.allow with input.user as user with input.session_counts as {"total": 1} - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} authorization_grant.allow with input.user as user with input.session_counts as {"total": 31} - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 32} - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 42} - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 65} - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured authorization_grant.allow with input.user as user with input.session_counts as {"total": 1} - with data.session_limit as null + with input.session_limit as null # Client credentials grant authorization_grant.allow with input.user as user with input.session_counts as null - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} } diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index 4f76842cd..05b0f4ebf 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -30,7 +30,7 @@ violation contains { "msg": "user has too many active sessions (soft limit)", } if { # Only apply if session limits are enabled in the config - data.session_limit != null + input.session_limit != null # This is a web-based interactive login is_interactive @@ -43,7 +43,7 @@ violation contains { # reached or exceeded. # We use the soft limit because the user will be able to interactively remove # sessions to return under the limit. - data.session_limit.soft_limit <= input.session_counts.total + input.session_limit.soft_limit <= input.session_counts.total } violation contains { @@ -51,7 +51,7 @@ violation contains { "msg": "user has too many active sessions (hard limit)", } if { # Only apply if session limits are enabled in the config - data.session_limit != null + input.session_limit != null # This is not a web-based interactive login not is_interactive @@ -64,7 +64,7 @@ violation contains { # reached or exceeded. # We don't use the soft limit because the user won't be able to interactively remove # sessions to return under the limit. - data.session_limit.hard_limit <= input.session_counts.total + input.session_limit.hard_limit <= input.session_counts.total } is_interactive if { diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 1b8049844..1b5874758 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -16,38 +16,38 @@ test_session_limiting_sso if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} compat_login.allow with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured compat_login.allow with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as null + with input.session_limit as null } # Test session limiting when using `m.login.password` @@ -56,32 +56,32 @@ test_session_limiting_password if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} compat_login.allow with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured compat_login.allow with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as null + with input.session_limit as null } test_no_session_limiting_upon_replacement if { @@ -89,11 +89,11 @@ test_no_session_limiting_upon_replacement if { with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + with input.session_limit as {"soft_limit": 32, "hard_limit": 64} } From ec540bf8ff48152592d57046df08fc2bd6db0486 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 18:40:34 -0500 Subject: [PATCH 20/70] Add `test_hard_limit_eviction_compat_login` --- crates/handlers/src/compat/login.rs | 77 +++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1bfdd34d6..1f35a3349 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1654,10 +1654,10 @@ mod tests { pool, SiteConfig { session_limit: Some(SessionLimitConfig { - // Lowest non-zero value so we don't have to login a bunch (lower - // than `hard_limit`) + // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - hard_limit: NonZeroU64::new(2).unwrap(), + // Lowest non-zero value so we don't have to login a bunch + hard_limit: NonZeroU64::new(1).unwrap(), hard_limit_eviction: false, }), ..test_site_config() @@ -1708,4 +1708,75 @@ mod tests { "Expected `errcode` to be `M_FORBIDDEN`" ); } + + /// Test that the `hard_limit_eviction` will automatically drop old sessions when we + /// go over the limit + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_hard_limit_eviction_compat_login(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + session_limit: Some(SessionLimitConfig { + // (doesn't matter) + soft_limit: NonZeroU64::new(1).unwrap(), + // Must be at-least 2 when `hard_limit_eviction` + hard_limit: NonZeroU64::new(2).unwrap(), + // Option under test + hard_limit_eviction: true, + }), + ..test_site_config() + }, + ) + .await + .unwrap(); + + let session_limit_config = state + .site_config + .session_limit + .as_ref() + .expect("Expected `session_limit` configured for this test"); + + let user = user_with_password(&state, "alice", "password", false).await; + + // Keep logging in to add more sessions, up to the `hard_limit` + #[allow(clippy::range_plus_one)] + for _ in 0..session_limit_config.hard_limit.get() { + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + } + + // One more login will drop one of our old sessions to make room for the new login + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + + // Ensure we still only have two sessions (`session_limit_config.hard_limit`) + let mut repo = state.repository().await.unwrap(); + let session_counts = count_user_sessions_for_limiting(&mut repo, &user) + .await + .unwrap(); + assert!( + session_counts.total == 2, + "Must not have more sessions ({}) than allowed by the `hard_limit` ({}). \ + Expected one of the old sessions to be dropped to make room for the new login", + session_counts.total, + session_limit_config.hard_limit, + ); + } } From 12b837fc802eff916cf33b8ed0a7cb3909584ded Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 19:29:18 -0500 Subject: [PATCH 21/70] Assert most recent --- crates/handlers/src/compat/login.rs | 80 +++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1f35a3349..5901e57b5 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -840,6 +840,7 @@ async fn user_password_login( #[cfg(test)] mod tests { + use std::collections::HashSet; use std::num::NonZeroU64; use hyper::Request; @@ -1701,10 +1702,10 @@ mod tests { let response = state.request(request.clone()).await; response.assert_status(StatusCode::FORBIDDEN); let body: serde_json::Value = response.json(); - assert!( + assert_eq!( body.get("errcode") - .expect("Expected errror response to include an `errcode`") - == "M_FORBIDDEN", + .expect("Expected errror response to include an `errcode`"), + "M_FORBIDDEN", "Expected `errcode` to be `M_FORBIDDEN`" ); } @@ -1739,9 +1740,11 @@ mod tests { let user = user_with_password(&state, "alice", "password", false).await; - // Keep logging in to add more sessions, up to the `hard_limit` + let mut login_device_ids: Vec = Vec::new(); + + // Keep logging in to add more sessions, up to the `hard_limit`. Then one more login will drop one of our old sessions to make room for the new login #[allow(clippy::range_plus_one)] - for _ in 0..session_limit_config.hard_limit.get() { + for _ in 0..(session_limit_config.hard_limit.get() + 1) { let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.password", "identifier": { @@ -1752,31 +1755,66 @@ mod tests { })); let response = state.request(request.clone()).await; response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let device_id = match body + .get("device_id") + .expect("Expected successful login response to include `device_id`") + { + serde_json::value::Value::String(device_id) => device_id.to_owned(), + _ => { + panic!("Expected `device_id` to be a string") + } + }; + login_device_ids.push(device_id); } - // One more login will drop one of our old sessions to make room for the new login - let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": "alice", - }, - "password": "password", - })); - let response = state.request(request.clone()).await; - response.assert_status(StatusCode::OK); - // Ensure we still only have two sessions (`session_limit_config.hard_limit`) let mut repo = state.repository().await.unwrap(); let session_counts = count_user_sessions_for_limiting(&mut repo, &user) .await .unwrap(); - assert!( - session_counts.total == 2, + assert_eq!( + session_counts.total, 2, "Must not have more sessions ({}) than allowed by the `hard_limit` ({}). \ Expected one of the old sessions to be dropped to make room for the new login", - session_counts.total, - session_limit_config.hard_limit, + session_counts.total, session_limit_config.hard_limit, + ); + + // Also ensure that they are the newest sessions (we dropped the oldest) + let compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new().for_user(&user).active_only(), + Pagination::first(2), + ) + .await + .expect("Should be able to list user's compat sessions"); + let remaining_active_sessions: HashSet = compat_session_page + .edges + .iter() + .map(|a| { + a.node + .0 + .device + .clone() + .expect("Expected each login should havea a device") + .as_str() + .to_owned() + }) + .collect(); + + let most_recent_login_devices: HashSet = login_device_ids + .iter() + .rev() + .take(2) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + assert!( + most_recent_login_devices.is_subset(&remaining_active_sessions), + "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?})", + remaining_active_sessions, + most_recent_login_devices ); } } From 3e76fe92e9c94b683b7ac91cbcbbee94e8bacba4 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Apr 2026 19:36:27 -0500 Subject: [PATCH 22/70] Tighten up descriptions --- crates/handlers/src/compat/login.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5901e57b5..8b9d3ef87 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1768,7 +1768,8 @@ mod tests { login_device_ids.push(device_id); } - // Ensure we still only have two sessions (`session_limit_config.hard_limit`) + // Ensure we still only have two sessions (`session_limit_config.hard_limit`). + // We're sanity checking across all session types. let mut repo = state.repository().await.unwrap(); let session_counts = count_user_sessions_for_limiting(&mut repo, &user) .await @@ -1780,7 +1781,7 @@ mod tests { session_counts.total, session_limit_config.hard_limit, ); - // Also ensure that they are the newest sessions (we dropped the oldest) + // Also ensure that the newest sessions remain (we dropped the oldest) let compat_session_page = repo .compat_session() .list( @@ -1789,7 +1790,7 @@ mod tests { ) .await .expect("Should be able to list user's compat sessions"); - let remaining_active_sessions: HashSet = compat_session_page + let remaining_active_compat_session_device_ids: HashSet = compat_session_page .edges .iter() .map(|a| { @@ -1803,18 +1804,26 @@ mod tests { }) .collect(); - let most_recent_login_devices: HashSet = login_device_ids + let most_recent_login_device_ids: HashSet = login_device_ids .iter() .rev() .take(2) .map(std::borrow::ToOwned::to_owned) .collect(); + // Sanity check our comparison (ensure we're not comparing an empty set) + assert_eq!( + most_recent_login_device_ids.len(), + 2, + "Expected 2 logins for the next comparison" + ); + // The remaining sessions should be the most recent sessions + #[allow(clippy::uninlined_format_args)] assert!( - most_recent_login_devices.is_subset(&remaining_active_sessions), + most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?})", - remaining_active_sessions, - most_recent_login_devices + remaining_active_compat_session_device_ids, + most_recent_login_device_ids ); } } From d02b216d7238bf35e9a2451868af4ae6300baca5 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 7 Apr 2026 15:45:38 -0500 Subject: [PATCH 23/70] Run `cargo +nightly fmt` --- crates/config/src/sections/experimental.rs | 46 ++++++++++++---------- crates/handlers/src/compat/login.rs | 19 ++++----- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 0b3691ce5..a0c656540 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -135,43 +135,47 @@ impl ConfigurationSection for ExperimentalConfig { /// Configuration options for the session limit feature #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SessionLimitConfig { - /// Upon login in interactive contexts (like OAuth 2.0 sessions), if the soft limit - /// is reached, it will display a policy violation screen (web UI) to remove - /// sessions before creating the new session. + /// Upon login in interactive contexts (like OAuth 2.0 sessions), if the + /// soft limit is reached, it will display a policy violation screen + /// (web UI) to remove sessions before creating the new session. /// - /// This is not enforced in non-interactive contexts (like the legacy compability - /// login API) as there is no opportunity for us to show some UI for people remove - /// some sessions. See [`hard_limit`] for enforcement on that side. + /// This is not enforced in non-interactive contexts (like the legacy + /// compability login API) as there is no opportunity for us to show + /// some UI for people remove some sessions. See [`hard_limit`] for + /// enforcement on that side. /// /// [`hard_limit`]: Self::hard_limit pub soft_limit: NonZeroU64, - /// Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy - /// violation error), otherwise, see [`hard_limit_eviction`]. + /// Upon login, when `hard_limit_eviction: false`, will refuse the new login + /// (policy violation error), otherwise, see [`hard_limit_eviction`]. /// - /// The hard limit is enforced in all contexts (interactive/non-interactive). + /// The hard limit is enforced in all contexts + /// (interactive/non-interactive). /// /// [`hard_limit_eviction`]: Self::hard_limit_eviction pub hard_limit: NonZeroU64, - /// Whether we should automatically choose the least recently used devices to remove - /// when the [`Self::hard_limit`] is reached; in order to allow the new login to continue. + /// Whether we should automatically choose the least recently used devices + /// to remove when the [`Self::hard_limit`] is reached; in order to + /// allow the new login to continue. /// /// Disabled by default /// - /// WARNING: Removing sessions is a potentially damaging operation. Any end-to-end - /// encrypted history on the device will be lost and can only be recovered if you - /// have another verified active device or have a recovery key setup. + /// WARNING: Removing sessions is a potentially damaging operation. Any + /// end-to-end encrypted history on the device will be lost and can only + /// be recovered if you have another verified active device or have a + /// recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be /// at-least 2 to avoid catastropically losing encrypted history and digital - /// identity in pathological cases. Keep in mind this is a bare minimum restriction - /// and you can still run into trouble. + /// identity in pathological cases. Keep in mind this is a bare minimum + /// restriction and you can still run into trouble. /// - /// This is most applicable in scenarios where your homeserver has many legacy - /// bots/scripts that login over and over (which ideally should be using [personal - /// access + /// This is most applicable in scenarios where your homeserver has many + /// legacy bots/scripts that login over and over (which ideally should + /// be using [personal access /// tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492)) - /// and you want to avoid breaking their operation while maintaining some level of - /// sanity with the number of devices that people can have. + /// and you want to avoid breaking their operation while maintaining some + /// level of sanity with the number of devices that people can have. /// /// [`hard_limit`]: Self::hard_limit /// [`hard_limit_eviction`]: Self::hard_limit_eviction diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 8b9d3ef87..7336623a7 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -492,7 +492,8 @@ pub(crate) async fn post( })) } -/// Given the violations from [`Policy::evaluate_compat_login`], return the appropriate `RouteError` response. +/// Given the violations from [`Policy::evaluate_compat_login`], return the +/// appropriate `RouteError` response. async fn process_violations_for_compat_login( clock: &dyn Clock, repo: &mut BoxRepository, @@ -840,8 +841,7 @@ async fn user_password_login( #[cfg(test)] mod tests { - use std::collections::HashSet; - use std::num::NonZeroU64; + use std::{collections::HashSet, num::NonZeroU64}; use hyper::Request; use mas_matrix::{HomeserverConnection, ProvisionRequest}; @@ -1594,9 +1594,9 @@ mod tests { /// Test that the `soft_limit` is not enforced for compat login. /// - /// `soft_limit` is for when we allow the user to remove devices in interactive - /// contexts. With the compatibility login API, there is no opportunity for us to - /// present a web UI. + /// `soft_limit` is for when we allow the user to remove devices in + /// interactive contexts. With the compatibility login API, there is no + /// opportunity for us to present a web UI. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_soft_limit_does_not_affect_compat_login(pool: PgPool) { setup(); @@ -1710,8 +1710,8 @@ mod tests { ); } - /// Test that the `hard_limit_eviction` will automatically drop old sessions when we - /// go over the limit + /// Test that the `hard_limit_eviction` will automatically drop old sessions + /// when we go over the limit #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_hard_limit_eviction_compat_login(pool: PgPool) { setup(); @@ -1742,7 +1742,8 @@ mod tests { let mut login_device_ids: Vec = Vec::new(); - // Keep logging in to add more sessions, up to the `hard_limit`. Then one more login will drop one of our old sessions to make room for the new login + // Keep logging in to add more sessions, up to the `hard_limit`. Then one more + // login will drop one of our old sessions to make room for the new login #[allow(clippy::range_plus_one)] for _ in 0..(session_limit_config.hard_limit.get() + 1) { let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ From fe2ce41009e1228cfa654cdf461ea1f444f65d01 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 7 Apr 2026 15:49:02 -0500 Subject: [PATCH 24/70] Run `sh misc/update.sh` --- docs/config.schema.json | 6 +++--- policies/schema/authorization_grant_input.json | 6 ++++++ policies/schema/compat_login_input.json | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/config.schema.json b/docs/config.schema.json index 72b85a7e3..042bbafbd 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2898,19 +2898,19 @@ "type": "object", "properties": { "soft_limit": { - "description": "Upon login in interactive contexts (like OAuth 2.0 sessions), if the soft limit\n is reached, it will display a policy violation screen (web UI) to remove\n sessions before creating the new session.\n\n This is not enforced in non-interactive contexts (like the legacy compability\n login API) as there is no opportunity for us to show some UI for people remove\n some sessions. See [`hard_limit`] for enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", + "description": "Upon login in interactive contexts (like OAuth 2.0 sessions), if the\n soft limit is reached, it will display a policy violation screen\n (web UI) to remove sessions before creating the new session.\n\n This is not enforced in non-interactive contexts (like the legacy\n compability login API) as there is no opportunity for us to show\n some UI for people remove some sessions. See [`hard_limit`] for\n enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit": { - "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login (policy\n violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts (interactive/non-interactive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login\n (policy violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts\n (interactive/non-interactive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "integer", "format": "uint64", "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices to remove\n when the [`Self::hard_limit`] is reached; in order to allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any end-to-end\n encrypted history on the device will be lost and can only be recovered if you\n have another verified active device or have a recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastropically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum restriction\n and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many legacy\n bots/scripts that login over and over (which ideally should be using [personal\n access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some level of\n sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastropically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } diff --git a/policies/schema/authorization_grant_input.json b/policies/schema/authorization_grant_input.json index 8f346cc5c..e9b7ff3b7 100644 --- a/policies/schema/authorization_grant_input.json +++ b/policies/schema/authorization_grant_input.json @@ -11,6 +11,11 @@ ], "additionalProperties": true }, + "session_limit": { + "description": "Limits on the number of application sessions that each user can have", + "type": "object", + "additionalProperties": true + }, "session_counts": { "description": "How many sessions the user has.\n Not populated if it's not a user logging in.", "anyOf": [ @@ -37,6 +42,7 @@ } }, "required": [ + "session_limit", "client", "scope", "grant_type", diff --git a/policies/schema/compat_login_input.json b/policies/schema/compat_login_input.json index ffb182de4..85e87e447 100644 --- a/policies/schema/compat_login_input.json +++ b/policies/schema/compat_login_input.json @@ -8,6 +8,11 @@ "type": "object", "additionalProperties": true }, + "session_limit": { + "description": "Limits on the number of application sessions that each user can have", + "type": "object", + "additionalProperties": true + }, "session_counts": { "description": "How many sessions the user has.", "allOf": [ @@ -34,6 +39,7 @@ }, "required": [ "user", + "session_limit", "session_counts", "session_replaced", "login", From a080e95bc05a6dcce3e312ca99359675537c950e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 7 Apr 2026 15:56:22 -0500 Subject: [PATCH 25/70] Better `clippy::uninlined_format_args` --- crates/handlers/src/compat/login.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 7336623a7..5ce833732 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1820,11 +1820,13 @@ mod tests { // The remaining sessions should be the most recent sessions #[allow(clippy::uninlined_format_args)] - assert!( - most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), - "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?})", - remaining_active_compat_session_device_ids, - most_recent_login_device_ids - ); + { + assert!( + most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), + "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?})", + remaining_active_compat_session_device_ids, + most_recent_login_device_ids + ); + } } } From 8a3acae1ae8b5ffa8853f2ae82f84824c968d44a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 10:08:31 -0500 Subject: [PATCH 26/70] Revert "Pass in `session_limit_config` directly to policy" This reverts commit 724e0cf5ca59cff5e59166bdb435af727ddf7cf4. --- crates/cli/src/commands/debug.rs | 9 +++++-- crates/cli/src/commands/server.rs | 4 ++- crates/cli/src/util.rs | 15 +++++++++-- crates/handlers/src/compat/login.rs | 2 -- .../handlers/src/compat/login_sso_complete.rs | 10 +------ .../src/oauth2/authorization/consent.rs | 9 +------ crates/handlers/src/oauth2/device/consent.rs | 8 +----- crates/handlers/src/oauth2/token.rs | 3 --- crates/handlers/src/test_utils.rs | 2 +- crates/policy/src/lib.rs | 18 ++++++++----- crates/policy/src/model.rs | 10 +------ .../authorization_grant.rego | 4 +-- .../authorization_grant_test.rego | 14 +++++----- policies/compat_login/compat_login.rego | 8 +++--- policies/compat_login/compat_login_test.rego | 26 +++++++++---------- 15 files changed, 66 insertions(+), 76 deletions(-) diff --git a/crates/cli/src/commands/debug.rs b/crates/cli/src/commands/debug.rs index bb87c5e81..6da64f95b 100644 --- a/crates/cli/src/commands/debug.rs +++ b/crates/cli/src/commands/debug.rs @@ -9,7 +9,8 @@ use std::process::ExitCode; use clap::Parser; use figment::Figment; use mas_config::{ - ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PolicyConfig, + ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, ExperimentalConfig, + MatrixConfig, PolicyConfig, }; use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; @@ -45,8 +46,12 @@ impl Options { PolicyConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; let matrix_config = MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; + let experimental_config = + ExperimentalConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!("Loading and compiling the policy module"); - let policy_factory = policy_factory_from_config(&config, &matrix_config).await?; + let policy_factory = + policy_factory_from_config(&config, &matrix_config, &experimental_config) + .await?; if with_dynamic_data { let database_config = diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 12f9e8a14..b72d48111 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -127,7 +127,9 @@ impl Options { // Load and compile the WASM policies (and fallback to the default embedded one) info!("Loading and compiling the policy module"); - let policy_factory = policy_factory_from_config(&config.policy, &config.matrix).await?; + let policy_factory = + policy_factory_from_config(&config.policy, &config.matrix, &config.experimental) + .await?; let policy_factory = Arc::new(policy_factory); load_policy_factory_dynamic_data_continuously( diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 8f5989305..caa76664d 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -135,6 +135,7 @@ pub fn test_mailer_in_background(mailer: &Mailer, timeout: Duration) { pub async fn policy_factory_from_config( config: &PolicyConfig, matrix_config: &MatrixConfig, + experimental_config: &ExperimentalConfig, ) -> Result { let policy_file = tokio::fs::File::open(&config.wasm_module) .await @@ -148,8 +149,18 @@ pub async fn policy_factory_from_config( email: config.email_entrypoint.clone(), }; - let data = - mas_policy::Data::new(matrix_config.homeserver.clone()).with_rest(config.data.clone()); + let session_limit_config = + experimental_config + .session_limit + .as_ref() + .map(|c| SessionLimitConfig { + soft_limit: c.soft_limit, + hard_limit: c.hard_limit, + hard_limit_eviction: c.hard_limit_eviction, + }); + + let data = mas_policy::Data::new(matrix_config.homeserver.clone(), session_limit_config) + .with_rest(config.data.clone()); PolicyFactory::load(policy_file, data, entrypoints) .await diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5ce833732..86ffd7641 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -685,7 +685,6 @@ async fn token_login( let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &browser_session.user, - session_limit: session_limit_config, login: CompatLogin::Token, session_replaced, session_counts, @@ -813,7 +812,6 @@ async fn user_password_login( let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &user, - session_limit: session_limit_config, login: CompatLogin::Password, session_replaced, session_counts, diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 1a1dbcdc5..df059cd36 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -19,7 +19,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, Clock, MatrixUser, SiteConfig}; +use mas_data_model::{BoxClock, BoxRng, Clock, MatrixUser}; use mas_matrix::HomeserverConnection; use mas_policy::{Policy, model::CompatLogin}; use mas_router::{CompatLoginSsoAction, UrlBuilder}; @@ -53,7 +53,6 @@ pub async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, - State(site_config): State, mut policy: Policy, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -115,12 +114,9 @@ pub async fn get( // We can close the repository early, we don't need it at this point repo.save().await?; - let session_limit_config = site_config.session_limit.as_ref(); - let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &session.user, - session_limit: session_limit_config, login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, @@ -197,7 +193,6 @@ pub async fn post( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(url_builder): State, - State(site_config): State, mut policy: Policy, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -267,12 +262,9 @@ pub async fn post( let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; - let session_limit_config = site_config.session_limit.as_ref(); - let res = policy .evaluate_compat_login(mas_policy::CompatLoginInput { user: &session.user, - session_limit: session_limit_config, login: CompatLogin::Sso { redirect_uri: login.redirect_uri.to_string(), }, diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index 31fdb8b3e..ab51bef1c 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -17,7 +17,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser, SiteConfig}; +use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser}; use mas_keystore::Keystore; use mas_matrix::HomeserverConnection; use mas_policy::Policy; @@ -91,7 +91,6 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, - State(site_config): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -146,12 +145,9 @@ pub(crate) async fn get( // We can close the repository early, we don't need it at this point repo.save().await?; - let session_limit_config = site_config.session_limit.as_ref(); - let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&session.user), - session_limit: session_limit_config, client: &client, session_counts: Some(session_counts), scope: &grant.scope, @@ -224,7 +220,6 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(key_store): State, - State(site_config): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -280,13 +275,11 @@ pub(crate) async fn post( return Err(RouteError::GrantNotPending(grant.id)); } - let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user).await?; let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&browser_session.user), - session_limit: session_limit_config, client: &client, session_counts: Some(session_counts), scope: &grant.scope, diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index b39a0e159..3912d2dc1 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -18,7 +18,7 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, MatrixUser, SiteConfig}; +use mas_data_model::{BoxClock, BoxRng, MatrixUser}; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; @@ -53,7 +53,6 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, - State(site_config): State, mut repo: BoxRepository, mut policy: Policy, activity_tracker: BoundActivityTracker, @@ -108,7 +107,6 @@ pub(crate) async fn get( .context("Client not found") .map_err(InternalError::from_anyhow)?; - let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // We can close the repository early, we don't need it at this point @@ -119,7 +117,6 @@ pub(crate) async fn get( .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, - session_limit: session_limit_config, session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), @@ -194,7 +191,6 @@ pub(crate) async fn post( State(templates): State, State(url_builder): State, State(homeserver): State>, - State(site_config): State, mut repo: BoxRepository, mut policy: Policy, activity_tracker: BoundActivityTracker, @@ -250,7 +246,6 @@ pub(crate) async fn post( .context("Client not found") .map_err(InternalError::from_anyhow)?; - let session_limit_config = site_config.session_limit.as_ref(); let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // Evaluate the policy @@ -258,7 +253,6 @@ pub(crate) async fn post( .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, - session_limit: session_limit_config, session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index d19ca9c5e..696c7d426 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -792,13 +792,10 @@ async fn client_credentials_grant( .clone() .unwrap_or_else(|| std::iter::empty::().collect()); - let session_limit_config = site_config.session_limit.as_ref(); - // Make the request go through the policy engine let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: None, - session_limit: session_limit_config, client, session_counts: None, scope: &scope, diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index a2bbf7d4a..521a4848d 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -86,7 +86,7 @@ pub(crate) async fn policy_factory( email: "email/violation".to_owned(), }; - let data = mas_policy::Data::new(server_name.to_owned()).with_rest(data); + let data = mas_policy::Data::new(server_name.to_owned(), None).with_rest(data); let policy_factory = PolicyFactory::load(file, data, entrypoints).await?; let policy_factory = Arc::new(policy_factory); diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index af715909b..a5d4805ad 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -9,7 +9,7 @@ pub mod model; use std::sync::Arc; use arc_swap::ArcSwap; -use mas_data_model::Ulid; +use mas_data_model::{SessionLimitConfig, Ulid}; use opa_wasm::{ Runtime, wasmtime::{Config, Engine, Module, OptLevel, Store}, @@ -100,13 +100,19 @@ pub struct Data { #[derive(Serialize, Debug)] struct BaseData { server_name: String, + + /// Limits on the number of application sessions that each user can have + session_limit: Option, } impl Data { #[must_use] - pub fn new(server_name: String) -> Self { + pub fn new(server_name: String, session_limit: Option) -> Self { Self { - base: BaseData { server_name }, + base: BaseData { + server_name, + session_limit, + }, rest: None, } @@ -501,7 +507,7 @@ mod tests { #[tokio::test] async fn test_register() { - let data = Data::new("example.com".to_owned()).with_rest(serde_json::json!({ + let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ "allowed_domains": ["element.io", "*.element.io"], "banned_domains": ["staging.element.io"], })); @@ -566,7 +572,7 @@ mod tests { #[tokio::test] async fn test_dynamic_data() { - let data = Data::new("example.com".to_owned()); + let data = Data::new("example.com".to_owned(), None); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -630,7 +636,7 @@ mod tests { #[tokio::test] async fn test_big_dynamic_data() { - let data = Data::new("example.com".to_owned()); + let data = Data::new("example.com".to_owned(), None); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index b8be99bae..a3bf24b5f 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -11,7 +11,7 @@ use std::net::IpAddr; -use mas_data_model::{Client, SessionLimitConfig, User}; +use mas_data_model::{Client, User}; use oauth2_types::{registration::VerifiedClientMetadata, scope::Scope}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -179,10 +179,6 @@ pub struct AuthorizationGrantInput<'a> { #[schemars(with = "Option>")] pub user: Option<&'a User>, - /// Limits on the number of application sessions that each user can have - #[schemars(with = "std::collections::HashMap")] - pub session_limit: Option<&'a SessionLimitConfig>, - /// How many sessions the user has. /// Not populated if it's not a user logging in. pub session_counts: Option, @@ -205,10 +201,6 @@ pub struct CompatLoginInput<'a> { #[schemars(with = "std::collections::HashMap")] pub user: &'a User, - /// Limits on the number of application sessions that each user can have - #[schemars(with = "std::collections::HashMap")] - pub session_limit: Option<&'a SessionLimitConfig>, - /// How many sessions the user has. pub session_counts: SessionCounts, diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index d7631a538..e7d1e68e5 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -159,7 +159,7 @@ violation contains { "msg": "user has too many active sessions", } if { # Only apply if session limits are enabled in the config - input.session_limit != null + data.session_limit != null # Only apply if it's a user logging in (who therefore has countable sessions) input.session_counts != null @@ -168,5 +168,5 @@ violation contains { # reached or exceeded. # We use the soft limit because the user will be able to interactively remove # sessions to return under the limit. - input.session_limit.soft_limit <= input.session_counts.total + data.session_limit.soft_limit <= input.session_counts.total } diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index a0752367e..e2ca74086 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -226,31 +226,31 @@ test_mas_scopes if { test_session_limiting if { authorization_grant.allow with input.user as user with input.session_counts as {"total": 1} - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} authorization_grant.allow with input.user as user with input.session_counts as {"total": 31} - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 32} - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 42} - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not authorization_grant.allow with input.user as user with input.session_counts as {"total": 65} - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured authorization_grant.allow with input.user as user with input.session_counts as {"total": 1} - with input.session_limit as null + with data.session_limit as null # Client credentials grant authorization_grant.allow with input.user as user with input.session_counts as null - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} } diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index 05b0f4ebf..4f76842cd 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -30,7 +30,7 @@ violation contains { "msg": "user has too many active sessions (soft limit)", } if { # Only apply if session limits are enabled in the config - input.session_limit != null + data.session_limit != null # This is a web-based interactive login is_interactive @@ -43,7 +43,7 @@ violation contains { # reached or exceeded. # We use the soft limit because the user will be able to interactively remove # sessions to return under the limit. - input.session_limit.soft_limit <= input.session_counts.total + data.session_limit.soft_limit <= input.session_counts.total } violation contains { @@ -51,7 +51,7 @@ violation contains { "msg": "user has too many active sessions (hard limit)", } if { # Only apply if session limits are enabled in the config - input.session_limit != null + data.session_limit != null # This is not a web-based interactive login not is_interactive @@ -64,7 +64,7 @@ violation contains { # reached or exceeded. # We don't use the soft limit because the user won't be able to interactively remove # sessions to return under the limit. - input.session_limit.hard_limit <= input.session_counts.total + data.session_limit.hard_limit <= input.session_counts.total } is_interactive if { diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 1b5874758..1b8049844 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -16,38 +16,38 @@ test_session_limiting_sso if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} compat_login.allow with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured compat_login.allow with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as null + with data.session_limit as null } # Test session limiting when using `m.login.password` @@ -56,32 +56,32 @@ test_session_limiting_password if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} compat_login.allow with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} # No limit configured compat_login.allow with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as null + with data.session_limit as null } test_no_session_limiting_upon_replacement if { @@ -89,11 +89,11 @@ test_no_session_limiting_upon_replacement if { with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - with input.session_limit as {"soft_limit": 32, "hard_limit": 64} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} } From fcf6591588805384b48a26d67e03d9831474a9e5 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 10:39:34 -0500 Subject: [PATCH 27/70] Pass in `session_limit` to policy as `BaseData` --- crates/cli/src/util.rs | 5 ++++- crates/handlers/src/graphql/tests.rs | 10 ++++++++-- crates/handlers/src/oauth2/token.rs | 5 ++++- crates/handlers/src/test_utils.rs | 19 +++++++++++++----- crates/policy/src/lib.rs | 29 +++++++++++++++++----------- 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index caa76664d..e1d48b606 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -159,7 +159,10 @@ pub async fn policy_factory_from_config( hard_limit_eviction: c.hard_limit_eviction, }); - let data = mas_policy::Data::new(matrix_config.homeserver.clone(), session_limit_config) + let data = mas_policy::Data::new(mas_policy::BaseData { + server_name: matrix_config.homeserver.clone(), + session_limit: session_limit_config + }) .with_rest(config.data.clone()); PolicyFactory::load(policy_file, data, entrypoints) diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 7fed79c09..898df9a56 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -470,7 +470,10 @@ async fn test_oauth2_client_credentials(pool: PgPool) { let state = { let mut state = state; state.policy_factory = test_utils::policy_factory( - "example.com", + mas_policy::BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }, serde_json::json!({ "admin_clients": [client_id], }), @@ -596,7 +599,10 @@ async fn test_add_user(pool: PgPool) { let state = { let mut state = state; state.policy_factory = test_utils::policy_factory( - "example.com", + mas_policy::BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }, serde_json::json!({ "admin_clients": [client_id], }), diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 696c7d426..49dd7cb68 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -1644,7 +1644,10 @@ mod tests { let state = { let mut state = state; state.policy_factory = crate::test_utils::policy_factory( - "example.com", + mas_policy::BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }, serde_json::json!({ "admin_clients": [client_id] }), diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 521a4848d..619d995a7 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -69,7 +69,7 @@ pub(crate) fn setup() { } pub(crate) async fn policy_factory( - server_name: &str, + base_data: mas_policy::BaseData, data: serde_json::Value, ) -> Result, anyhow::Error> { let workspace_root = camino::Utf8Path::new(env!("CARGO_MANIFEST_DIR")) @@ -86,7 +86,7 @@ pub(crate) async fn policy_factory( email: "email/violation".to_owned(), }; - let data = mas_policy::Data::new(server_name.to_owned(), None).with_rest(data); + let data = mas_policy::Data::new(base_data).with_rest(data); let policy_factory = PolicyFactory::load(file, data, entrypoints).await?; let policy_factory = Arc::new(policy_factory); @@ -207,8 +207,14 @@ impl TestState { PasswordManager::disabled() }; - let policy_factory = - policy_factory(&site_config.server_name, serde_json::json!({})).await?; + let policy_factory = policy_factory( + mas_policy::BaseData { + server_name: site_config.server_name.clone(), + session_limit: site_config.session_limit.clone(), + }, + serde_json::json!({}), + ) + .await?; let homeserver_connection = Arc::new(MockHomeserverConnection::new(&site_config.server_name)); @@ -365,7 +371,10 @@ impl TestState { let state = { let mut state = self.clone(); state.policy_factory = policy_factory( - "example.com", + mas_policy::BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }, serde_json::json!({ "admin_clients": [client_id], }), diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index a5d4805ad..e220e70a0 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -98,21 +98,18 @@ pub struct Data { } #[derive(Serialize, Debug)] -struct BaseData { - server_name: String, +pub struct BaseData { + pub server_name: String, /// Limits on the number of application sessions that each user can have - session_limit: Option, + pub session_limit: Option, } impl Data { #[must_use] - pub fn new(server_name: String, session_limit: Option) -> Self { + pub fn new(base_data: BaseData) -> Self { Self { - base: BaseData { - server_name, - session_limit, - }, + base: base_data, rest: None, } @@ -507,7 +504,11 @@ mod tests { #[tokio::test] async fn test_register() { - let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ + let data = Data::new(BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }) + .with_rest(serde_json::json!({ "allowed_domains": ["element.io", "*.element.io"], "banned_domains": ["staging.element.io"], })); @@ -572,7 +573,10 @@ mod tests { #[tokio::test] async fn test_dynamic_data() { - let data = Data::new("example.com".to_owned(), None); + let data = Data::new(BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -636,7 +640,10 @@ mod tests { #[tokio::test] async fn test_big_dynamic_data() { - let data = Data::new("example.com".to_owned(), None); + let data = Data::new(BaseData { + server_name: "example.com".to_owned(), + session_limit: None, + }); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) From 9b3e78909a0a49b4433f0d12bc1931fe05629c7e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 16:58:08 -0500 Subject: [PATCH 28/70] Fix `cargo doc` not being able to resolve `ExperimentalSessionLimitConfig` Fix https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3047813509 --- crates/data-model/src/site_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index c27cbeb05..2c04c2438 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -39,7 +39,7 @@ pub struct SessionExpirationConfig { pub compat_session_inactivity_ttl: Option, } -/// See [`mas_config::sections::ExperimentalSessionLimitConfig`] +/// See [`mas_config::ExperimentalSessionLimitConfig`] #[derive(Serialize, Debug, Clone)] pub struct SessionLimitConfig { pub soft_limit: NonZeroU64, From 9284324663af4bff35315264b0252938cbad2b70 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 17:22:10 -0500 Subject: [PATCH 29/70] Use slice pattern matching See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3058047468 --- crates/handlers/src/compat/login.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 86ffd7641..8b73cfa75 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -501,13 +501,19 @@ async fn process_violations_for_compat_login( user: &User, violations: Vec, ) -> Result<(), RouteError> { - match (violations.len(), violations.first()) { + // We're using slice syntax here so we can match easily + match &violations[..] { // If the only violation is having reached the session limit, we might be // able to resolve the situation. // // We don't trigger this if there was some other violation anyway, since // that means that removing a session wouldn't actually unblock the login. - (1, Some(violation)) if violation.variant == Some(ViolationVariant::TooManySessions) => { + [ + Violation { + variant: Some(ViolationVariant::TooManySessions), + .. + }, + ] => { let session_limit_config = session_limit_config .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ This is most likely a programming error."); @@ -573,9 +579,9 @@ async fn process_violations_for_compat_login( } } // Nothing is wrong - (0, None) => return Ok(()), + [] => return Ok(()), // Just throw an error for any other violation - _ => { + _violations => { return Err(RouteError::PolicyRejected); } } From 37a1a46d5411259aa4972dc26e1972969724e3d7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 17:50:19 -0500 Subject: [PATCH 30/70] Add future FIXME to expose violations See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3058112947 --- crates/handlers/src/compat/login.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 8b73cfa75..30debdf99 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -582,6 +582,7 @@ async fn process_violations_for_compat_login( [] => return Ok(()), // Just throw an error for any other violation _violations => { + // FIXME: We should be exposing the violations to the user return Err(RouteError::PolicyRejected); } } From dfe77c4b82f210b170afbf5e3a3da24d98ec4f76 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 19:43:28 -0500 Subject: [PATCH 31/70] Cheeky filter inactive See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3057979830 --- Cargo.toml | 4 +- crates/handlers/src/compat/login.rs | 106 +++++++++++++++++++++------- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 741a39b38..f5ea11ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,9 @@ unsafe_code = "deny" # We use groups as good defaults, but with a lower priority so that we can override them all = { level = "deny", priority = -1 } pedantic = { level = "warn", priority = -1 } - +# Allowed because it's nice to have temporary semantic names, see +# https://github.com/rust-lang/rust-clippy/issues/12512#issuecomment-3316736180 +let_and_return = { level = "allow", priority = 1 } str_to_string = "deny" too_many_lines = "allow" diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 30debdf99..be44e3bf0 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -4,6 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use axum::{Json, extract::State, response::IntoResponse}; @@ -531,7 +532,7 @@ async fn process_violations_for_compat_login( // automatically remove their oldest devices (when `hard_limit_eviction` // is configured). if session_limit_config.hard_limit_eviction { - // Find the least recently used compat sessions + // Find the least recently used (LRU) compat sessions // // In the future, it may be nice to avoid sessions with // cryptographic state (what does that mean exactly? keys uploaded @@ -540,38 +541,91 @@ async fn process_violations_for_compat_login( // FIXME: We could potentially use // `repo.compat_session().finish_bulk(...)` if it had the ability to // limit and order. - let compat_session_page = repo - .compat_session() - .list( - // TODO: Order by `last_active_at` - CompatSessionFilter::new().for_user(user).active_only(), - Pagination::first(need_to_remove_usize), - ) - .await?; + let lru_compat_sessions = { + // TODO: In the future, instead of all of this faff, we can simply order + // by `last_active_at` + // + // XXX: Since we can't order by `last_active_at` yet, we instead filter + // the list down to "inactive" sessions (`last_active_at` > 90 days + // ago). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a `Ulid`/`Uuid` + // (the query is ordered by `compat_session_id`), the first bytes are a + // timestamp so we'll be getting the 'oldest created' sessions which is + // another good proxy. + + let mut edges_to_consider = Vec::new(); + + // First, find the "inactive" sessions + let inactive_threshold_date = clock.now() - Duration::days(90); + let inactive_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_before(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove_usize, 100)), + ) + .await?; + edges_to_consider.extend(inactive_compat_session_page.edges); + + // If there aren't enough "inactive" sessions, supplement with active ones + if edges_to_consider.len() < need_to_remove_usize { + let active_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_after(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove_usize, 100)), + ) + .await?; + edges_to_consider.extend(active_compat_session_page.edges); + } + + // De-duplicate the sessions across both pages + let compat_session_map = { + let mut compat_session_map = HashMap::new(); + for edge in edges_to_consider { + let (compat_session, _) = edge.node; + compat_session_map.insert(compat_session.id, compat_session); + } + compat_session_map + }; + + // List of compat sessions sorted by `last_active_at` ascending + let sorted_compat_sessions = { + let mut compat_sessions: Vec = + compat_session_map.into_values().collect(); + // Sort by `last_active_at` (ascending) + compat_sessions.sort_by_key(|compat_session| compat_session.last_active_at); + compat_sessions + }; + + sorted_compat_sessions + }; // For now, we only automatically clean up compatibility sessions. // If there aren't enough sessions that we could clean up, we just // throw an error with an explanation. - if compat_session_page.edges.len() < need_to_remove_usize { + if lru_compat_sessions.len() < need_to_remove_usize { return Err(RouteError::PolicyHardSessionLimitReached); } - // Remove the sessions - let num_sessions_removed = { - let mut num_sessions_removed = 0; - for edge in compat_session_page.edges { - let (compat_session, _) = edge.node; - repo.compat_session().finish(clock, compat_session).await?; - num_sessions_removed += 1; - } - num_sessions_removed - }; - - // For now, we only automatically clean up compatibility sessions. - // If there are still too many sessions, we just throw an error with - // an explanation. - if num_sessions_removed < need_to_remove { - return Err(RouteError::PolicyHardSessionLimitReached); + // Remove the sessions (only as much as necessary, `need_to_remove`) + for compat_session in &lru_compat_sessions[0..need_to_remove_usize] { + repo.compat_session() + .finish(clock, compat_session.to_owned()) + .await?; } } else { // Tell the user about the limit From 93312fb97b195104f9ccc6d71e907cf66ebbaa50 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 21:36:39 -0500 Subject: [PATCH 32/70] Add tests for old vs recent --- crates/cli/src/util.rs | 4 +- crates/handlers/src/compat/login.rs | 184 ++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index e1d48b606..2bf35f3c3 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -161,9 +161,9 @@ pub async fn policy_factory_from_config( let data = mas_policy::Data::new(mas_policy::BaseData { server_name: matrix_config.homeserver.clone(), - session_limit: session_limit_config + session_limit: session_limit_config, }) - .with_rest(config.data.clone()); + .with_rest(config.data.clone()); PolicyFactory::load(policy_file, data, entrypoints) .await diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index be44e3bf0..7d7df4a64 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -4,8 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::collections::HashMap; -use std::sync::{Arc, LazyLock}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; @@ -578,10 +580,11 @@ async fn process_violations_for_compat_login( let active_compat_session_page = repo .compat_session() .list( - CompatSessionFilter::new() - .for_user(user) - .active_only() - .with_last_active_after(inactive_threshold_date), + // If we try to use + // `.with_last_active_after(inactive_threshold_date)` + // here, it will exclude all of the rows where + // `last_active_at` is null which we want to include. + CompatSessionFilter::new().for_user(user).active_only(), // We fetch a minimum of 100 sessions (more than we need in // normal cases) so we can sort by `last_active_at` after it // gets back from the database and can get even closer to @@ -900,7 +903,7 @@ async fn user_password_login( #[cfg(test)] mod tests { - use std::{collections::HashSet, num::NonZeroU64}; + use std::{collections::HashSet, num::NonZeroU64, ops::Sub}; use hyper::Request; use mas_matrix::{HomeserverConnection, ProvisionRequest}; @@ -1772,7 +1775,167 @@ mod tests { /// Test that the `hard_limit_eviction` will automatically drop old sessions /// when we go over the limit #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_hard_limit_eviction_compat_login(pool: PgPool) { + async fn test_hard_limit_eviction_old_compat_login(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + session_limit: Some(SessionLimitConfig { + // (doesn't matter) + soft_limit: NonZeroU64::new(1).unwrap(), + // Must be at-least 2 when `hard_limit_eviction` + hard_limit: NonZeroU64::new(2).unwrap(), + // Option under test + hard_limit_eviction: true, + }), + ..test_site_config() + }, + ) + .await + .unwrap(); + + let session_limit_config = state + .site_config + .session_limit + .as_ref() + .expect("Expected `session_limit` configured for this test"); + + let user = user_with_password(&state, "alice", "password", false).await; + + let mut login_device_ids: Vec = Vec::new(); + + // Keep logging in to add more sessions, up to the `hard_limit`. Then `+ 1` for + // one more login will drop one of our old sessions to make room for the new + // login + #[allow(clippy::range_plus_one)] + for login_index in 0..(session_limit_config.hard_limit.get() + 1) { + let original_time = state.clock.now(); + // All of the logins except the last one should be in the past + if login_index <= session_limit_config.hard_limit.get() { + // Rewind time so the logins appear older than our "inactive" threshold (90 + // days) + let login_index_i64: i64 = login_index.try_into().unwrap(); + state + .clock + // Each login is a day earlier + .advance(Duration::days(-200 + login_index_i64)); + } + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let device_id = match body + .get("device_id") + .expect("Expected successful login response to include `device_id`") + { + serde_json::value::Value::String(device_id) => device_id.to_owned(), + _ => { + panic!("Expected `device_id` to be a string") + } + }; + login_device_ids.push(device_id); + + // Restore time + state.clock.advance(original_time - state.clock.now()); + } + + // TODO: How to wait for `last_active_at` to be set? + + // Sanity check that the compat sessions have `last_active_at` set. This is + // important as `last_active_at` starts out null. + let mut repo = state.repository().await.unwrap(); + let compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new().for_user(&user).active_only(), + Pagination::first(session_limit_config.hard_limit.get().try_into().unwrap()), + ) + .await + .expect("Should be able to list user's compat sessions"); + for edge in compat_session_page.edges { + let (compat_session, _) = edge.node; + let last_active_at = compat_session + .last_active_at + .expect("We expect compat sessions to have `last_active_at` set for this test"); + assert!( + last_active_at < (state.clock.now().sub(Duration::days(90))), + "Expected compat sessions to have a `last_active_at` older than the 90 day 'inactive' threshold" + ); + } + + // Ensure we still only have two sessions (`session_limit_config.hard_limit`). + // We're sanity checking across all session types. + let session_counts = count_user_sessions_for_limiting(&mut repo, &user) + .await + .unwrap(); + assert_eq!( + session_counts.total, 2, + "Must not have more sessions ({}) than allowed by the `hard_limit` ({}). \ + Expected one of the old sessions to be dropped to make room for the new login", + session_counts.total, session_limit_config.hard_limit, + ); + + // Also ensure that the newest sessions remain (we dropped the oldest) + let compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new().for_user(&user).active_only(), + Pagination::first(2), + ) + .await + .expect("Should be able to list user's compat sessions"); + let remaining_active_compat_session_device_ids: HashSet = compat_session_page + .edges + .iter() + .map(|a| { + a.node + .0 + .device + .clone() + .expect("Expected each login should havea a device") + .as_str() + .to_owned() + }) + .collect(); + + let most_recent_login_device_ids: HashSet = login_device_ids + .iter() + .rev() + .take(2) + .map(std::borrow::ToOwned::to_owned) + .collect(); + // Sanity check our comparison (ensure we're not comparing an empty set) + assert_eq!( + most_recent_login_device_ids.len(), + 2, + "Expected 2 logins for the next comparison" + ); + + // The remaining sessions should be the most recent sessions + #[allow(clippy::uninlined_format_args)] + { + assert!( + most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), + "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?}). (all logins: {:?})", + remaining_active_compat_session_device_ids, + most_recent_login_device_ids, + login_device_ids, + ); + } + } + + /// Test that the `hard_limit_eviction` will automatically drop old sessions + /// when we go over the limit + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_hard_limit_eviction_recent_compat_login(pool: PgPool) { setup(); let state = TestState::from_pool_with_site_config( pool, @@ -1882,9 +2045,10 @@ mod tests { { assert!( most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), - "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?})", + "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?}). (all logins: {:?})", remaining_active_compat_session_device_ids, - most_recent_login_device_ids + most_recent_login_device_ids, + login_device_ids, ); } } From 5e759925c8534e8e76d6154f38a59656bdcca625 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Apr 2026 21:48:09 -0500 Subject: [PATCH 33/70] Run `sh misc/update.sh` --- policies/schema/authorization_grant_input.json | 6 ------ policies/schema/compat_login_input.json | 6 ------ 2 files changed, 12 deletions(-) diff --git a/policies/schema/authorization_grant_input.json b/policies/schema/authorization_grant_input.json index e9b7ff3b7..8f346cc5c 100644 --- a/policies/schema/authorization_grant_input.json +++ b/policies/schema/authorization_grant_input.json @@ -11,11 +11,6 @@ ], "additionalProperties": true }, - "session_limit": { - "description": "Limits on the number of application sessions that each user can have", - "type": "object", - "additionalProperties": true - }, "session_counts": { "description": "How many sessions the user has.\n Not populated if it's not a user logging in.", "anyOf": [ @@ -42,7 +37,6 @@ } }, "required": [ - "session_limit", "client", "scope", "grant_type", diff --git a/policies/schema/compat_login_input.json b/policies/schema/compat_login_input.json index 85e87e447..ffb182de4 100644 --- a/policies/schema/compat_login_input.json +++ b/policies/schema/compat_login_input.json @@ -8,11 +8,6 @@ "type": "object", "additionalProperties": true }, - "session_limit": { - "description": "Limits on the number of application sessions that each user can have", - "type": "object", - "additionalProperties": true - }, "session_counts": { "description": "How many sessions the user has.", "allOf": [ @@ -39,7 +34,6 @@ }, "required": [ "user", - "session_limit", "session_counts", "session_replaced", "login", From d8b0e877f4fffe9b1e748bccc40fa3b5cfdba488 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 15:22:48 -0500 Subject: [PATCH 34/70] Better test description --- crates/handlers/src/compat/login.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 7d7df4a64..15d0219fd 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1932,8 +1932,8 @@ mod tests { } } - /// Test that the `hard_limit_eviction` will automatically drop old sessions - /// when we go over the limit + /// Test that the `hard_limit_eviction` will automatically drop the oldest sessions + /// when we go over the limit even if all of the sessions are recent. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_hard_limit_eviction_recent_compat_login(pool: PgPool) { setup(); From d37701128219307c5124852c12308772dea70503 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 15:55:38 -0500 Subject: [PATCH 35/70] Grab `need_to_remove` from violation As planned in https://github.com/element-hq/matrix-authentication-service/pull/5553 --- crates/handlers/src/compat/login.rs | 6 ++---- crates/policy/src/model.rs | 7 +++++-- crates/templates/src/context.rs | 2 +- policies/authorization_grant/authorization_grant.rego | 3 +++ policies/compat_login/compat_login.rego | 6 ++++++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 15d0219fd..c9eb110eb 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -513,7 +513,7 @@ async fn process_violations_for_compat_login( // that means that removing a session wouldn't actually unblock the login. [ Violation { - variant: Some(ViolationVariant::TooManySessions), + variant: Some(ViolationVariant::TooManySessions { need_to_remove }), .. }, ] => { @@ -521,9 +521,7 @@ async fn process_violations_for_compat_login( .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ This is most likely a programming error."); - // TODO: This should come from `ViolationVariant::TooManySessions` - let need_to_remove: u32 = 1; - let need_to_remove_usize = usize::try_from(need_to_remove).map_err(|err| { + let need_to_remove_usize = usize::try_from(*need_to_remove).map_err(|err| { RouteError::Internal( anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}").into(), ) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index a3bf24b5f..d5ad5b632 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -52,7 +52,10 @@ pub enum ViolationVariant { EmailBanned, /// The user has reached their session limit. - TooManySessions, + TooManySessions { + /// How many devices to remove + need_to_remove: u32, + }, } impl ViolationVariant { @@ -70,7 +73,7 @@ impl ViolationVariant { Self::EmailDomainBanned => "email-domain-banned", Self::EmailNotAllowed => "email-not-allowed", Self::EmailBanned => "email-banned", - Self::TooManySessions => "too-many-sessions", + Self::TooManySessions { need_to_remove: _ } => "too-many-sessions", } } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 25123970b..6bbc1177c 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -890,7 +890,7 @@ impl TemplateContext for CompatLoginPolicyViolationContext { msg: "user has too many active sessions".to_owned(), redirect_uri: None, field: None, - variant: Some(ViolationVariant::TooManySessions), + variant: Some(ViolationVariant::TooManySessions { need_to_remove: 1 }), }], }, ]) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index e7d1e68e5..2b0a55e4f 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -157,6 +157,9 @@ violation contains {"msg": sprintf( violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions", + # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # for the new session + "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 } if { # Only apply if session limits are enabled in the config data.session_limit != null diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index 4f76842cd..604a82ad2 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -28,6 +28,9 @@ violation contains {"msg": sprintf( violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions (soft limit)", + # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # for the new session + "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 } if { # Only apply if session limits are enabled in the config data.session_limit != null @@ -49,6 +52,9 @@ violation contains { violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions (hard limit)", + # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # for the new session + "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 } if { # Only apply if session limits are enabled in the config data.session_limit != null From d9135d5696b53c9abbd7cd38f4c4b027c5eacdbe Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 15:56:47 -0500 Subject: [PATCH 36/70] Prefer variable shadowing (`need_to_remove`) See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3058106230 --- crates/handlers/src/compat/login.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index c9eb110eb..50859eb1c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -521,7 +521,7 @@ async fn process_violations_for_compat_login( .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ This is most likely a programming error."); - let need_to_remove_usize = usize::try_from(*need_to_remove).map_err(|err| { + let need_to_remove = usize::try_from(*need_to_remove).map_err(|err| { RouteError::Internal( anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}").into(), ) @@ -568,13 +568,13 @@ async fn process_violations_for_compat_login( // normal cases) so we can sort by `last_active_at` after it // gets back from the database and can get even closer to // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove_usize, 100)), + Pagination::first(std::cmp::max(need_to_remove, 100)), ) .await?; edges_to_consider.extend(inactive_compat_session_page.edges); // If there aren't enough "inactive" sessions, supplement with active ones - if edges_to_consider.len() < need_to_remove_usize { + if edges_to_consider.len() < need_to_remove { let active_compat_session_page = repo .compat_session() .list( @@ -587,7 +587,7 @@ async fn process_violations_for_compat_login( // normal cases) so we can sort by `last_active_at` after it // gets back from the database and can get even closer to // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove_usize, 100)), + Pagination::first(std::cmp::max(need_to_remove, 100)), ) .await?; edges_to_consider.extend(active_compat_session_page.edges); @@ -618,12 +618,12 @@ async fn process_violations_for_compat_login( // For now, we only automatically clean up compatibility sessions. // If there aren't enough sessions that we could clean up, we just // throw an error with an explanation. - if lru_compat_sessions.len() < need_to_remove_usize { + if lru_compat_sessions.len() < need_to_remove { return Err(RouteError::PolicyHardSessionLimitReached); } // Remove the sessions (only as much as necessary, `need_to_remove`) - for compat_session in &lru_compat_sessions[0..need_to_remove_usize] { + for compat_session in &lru_compat_sessions[0..need_to_remove] { repo.compat_session() .finish(clock, compat_session.to_owned()) .await?; From b1cb96dd83b38f240519d5b8cc13e932428afd21 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 16:02:00 -0500 Subject: [PATCH 37/70] Lint/format policies - `make DOCKER=1 fmt` - `make DOCKER=1 lint` --- policies/authorization_grant/authorization_grant.rego | 2 +- policies/compat_login/compat_login.rego | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index 2b0a55e4f..229ba90a1 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -159,7 +159,7 @@ violation contains { "msg": "user has too many active sessions", # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room # for the new session - "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 + "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index 604a82ad2..8f6543eba 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -30,7 +30,7 @@ violation contains { "msg": "user has too many active sessions (soft limit)", # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room # for the new session - "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 + "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null @@ -54,7 +54,7 @@ violation contains { "msg": "user has too many active sessions (hard limit)", # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room # for the new session - "need_to_remove": input.session_counts.total - data.session_limit.hard_limit + 1 + "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null From eb813cbe494d6b99a4bf21ba8db60742f5362daa Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 16:19:22 -0500 Subject: [PATCH 38/70] Fix spacing typo --- policies/authorization_grant/authorization_grant.rego | 2 +- policies/compat_login/compat_login.rego | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index 229ba90a1..eca6ccbcf 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -157,7 +157,7 @@ violation contains {"msg": sprintf( violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions", - # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index 8f6543eba..dd505f8a5 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -28,7 +28,7 @@ violation contains {"msg": sprintf( violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions (soft limit)", - # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { @@ -52,7 +52,7 @@ violation contains { violation contains { "code": "too-many-sessions", "msg": "user has too many active sessions (hard limit)", - # `+ 1` because when you're at 2 sessions, and the limit is 2,you have to make room + # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, } if { From de366d577f76b6a5d088339d1cd187a1e3fce768 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Apr 2026 16:24:15 -0500 Subject: [PATCH 39/70] Run `cargo +nightly fmt` --- crates/handlers/src/compat/login.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 50859eb1c..35f278cd6 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1930,8 +1930,9 @@ mod tests { } } - /// Test that the `hard_limit_eviction` will automatically drop the oldest sessions - /// when we go over the limit even if all of the sessions are recent. + /// Test that the `hard_limit_eviction` will automatically drop the oldest + /// sessions when we go over the limit even if all of the sessions are + /// recent. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_hard_limit_eviction_recent_compat_login(pool: PgPool) { setup(); From 020fb58125d689a2dcbfac59a390878868396ed3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 15:26:09 -0500 Subject: [PATCH 40/70] Remove `uninlined_format_args` usage Fix https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3047833269 --- crates/handlers/src/compat/login.rs | 32 +++++++++++------------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 35f278cd6..e5be8a4dc 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1918,16 +1918,12 @@ mod tests { ); // The remaining sessions should be the most recent sessions - #[allow(clippy::uninlined_format_args)] - { - assert!( - most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), - "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?}). (all logins: {:?})", - remaining_active_compat_session_device_ids, - most_recent_login_device_ids, - login_device_ids, - ); - } + assert!( + most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), + "Expected the 2 remaining active sessions ({remaining:?}) to include the 2 most recent logins ({recent:?}). (all logins: {login_device_ids:?})", + remaining = remaining_active_compat_session_device_ids, + recent = most_recent_login_device_ids, + ); } /// Test that the `hard_limit_eviction` will automatically drop the oldest @@ -2040,15 +2036,11 @@ mod tests { ); // The remaining sessions should be the most recent sessions - #[allow(clippy::uninlined_format_args)] - { - assert!( - most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), - "Expected the 2 remaining active sessions ({:?}) to include the 2 most recent logins ({:?}). (all logins: {:?})", - remaining_active_compat_session_device_ids, - most_recent_login_device_ids, - login_device_ids, - ); - } + assert!( + most_recent_login_device_ids.is_subset(&remaining_active_compat_session_device_ids), + "Expected the 2 remaining active sessions ({remaining:?}) to include the 2 most recent logins ({recent:?}). (all logins: {login_device_ids:?})", + remaining = remaining_active_compat_session_device_ids, + recent = most_recent_login_device_ids, + ); } } From 1ff9d288d85c839a134ba1d4010a23ac51aead5d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 15:31:57 -0500 Subject: [PATCH 41/70] Allow clippy `uninlined_format_args` See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3075627261 --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f5ea11ea1..eb82aa0a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,12 @@ all = { level = "deny", priority = -1 } pedantic = { level = "warn", priority = -1 } # Allowed because it's nice to have temporary semantic names, see # https://github.com/rust-lang/rust-clippy/issues/12512#issuecomment-3316736180 -let_and_return = { level = "allow", priority = 1 } +let_and_return = "allow" str_to_string = "deny" too_many_lines = "allow" +# Sometimes variables are long and annoying to inline in the format string. +# And this lint also complains about aliases, https://github.com/rust-lang/rust-clippy/issues/10247 +uninlined_format_args = "allow" [workspace.lints.rustdoc] broken_intra_doc_links = "deny" From e4b8a3659f831fe74e1a6e6039d15b61b1d0d795 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 15:41:18 -0500 Subject: [PATCH 42/70] Fix `validate` error not extra metadata New error example: ``` Error: Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set. See configuration docs for more info. for key "default.experimental.session_limit.hard_limit" in config.yaml YAML file ``` See: - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3034406794 - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3057732795 --- crates/config/src/sections/experimental.rs | 30 +++++++++------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index a0c656540..a7165eed9 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -126,7 +126,14 @@ impl ConfigurationSection for ExperimentalConfig { figment: &figment::Figment, ) -> Result<(), Box> { if let Some(session_limit) = &self.session_limit { - session_limit.validate(figment)?; + session_limit.validate().map_err(|mut err| { + // Save the error location information in the error + err.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned(); + err.profile = Some(figment::Profile::Default); + err.path.insert(0, Self::PATH.unwrap().to_owned()); + err.path.insert(1, "session_limit".to_owned()); + err + })?; } Ok(()) } @@ -183,27 +190,14 @@ pub struct SessionLimitConfig { pub hard_limit_eviction: bool, } -impl ConfigurationSection for SessionLimitConfig { - const PATH: Option<&'static str> = Some("session_limit"); - - fn validate( - &self, - figment: &figment::Figment, - ) -> Result<(), Box> { - let metadata = figment.find_metadata(Self::PATH.unwrap()); - let error_on_field = |mut error: figment::error::Error, field: &'static str| { - error.metadata = metadata.cloned(); - error.profile = Some(figment::Profile::Default); - error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()]; - error - }; - +impl SessionLimitConfig { + fn validate(&self) -> Result<(), Box> { // See [`SessionLimitConfig::hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { - return Err(error_on_field(figment::error::Error::from( + return Err(figment::error::Error::from( "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set. \ See configuration docs for more info.", - ), "hard_limit").into()); + ).with_path("hard_limit").into()); } Ok(()) From d53ab882bf3dd110761b9f723986ea0a57f6d705 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 15:57:26 -0500 Subject: [PATCH 43/70] Wait for `last_active_at` to be set See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3061734830 --- crates/handlers/src/compat/login.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index e5be8a4dc..14064b479 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1841,12 +1841,13 @@ mod tests { }; login_device_ids.push(device_id); + // Wait for `last_active_at` to be set + state.activity_tracker.flush().await; + // Restore time state.clock.advance(original_time - state.clock.now()); } - // TODO: How to wait for `last_active_at` to be set? - // Sanity check that the compat sessions have `last_active_at` set. This is // important as `last_active_at` starts out null. let mut repo = state.repository().await.unwrap(); From 5162529b1646f243badc564839fbfc8e43dc6eff Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 16:38:45 -0500 Subject: [PATCH 44/70] Fix flaky recent eviction test --- crates/handlers/src/compat/login.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 14064b479..931962d27 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -608,7 +608,16 @@ async fn process_violations_for_compat_login( let mut compat_sessions: Vec = compat_session_map.into_values().collect(); // Sort by `last_active_at` (ascending) - compat_sessions.sort_by_key(|compat_session| compat_session.last_active_at); + compat_sessions.sort_by_key(|compat_session| { + ( + // We mainly care about sorting by `last_active_at` + compat_session.last_active_at, + // Tie-break based on `created_at` + compat_session.created_at, + // Tie-break based on `id` for determinism + compat_session.id, + ) + }); compat_sessions }; @@ -1985,6 +1994,13 @@ mod tests { } }; login_device_ids.push(device_id); + + // Advance time so that each session ID sorts deterministically after each + // other (ULID includes timestamp). We would have flaky tests without this. + state + .clock + // Each login comes after the next. + .advance(Duration::seconds(1)); } // Ensure we still only have two sessions (`session_limit_config.hard_limit`). From 12ce01fed9d88143aa573d0902c80f55855f30c0 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Apr 2026 16:43:14 -0500 Subject: [PATCH 45/70] Explicitly mention that it doesn't matter for this test --- crates/handlers/src/compat/login.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 931962d27..2d3d204d5 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1995,6 +1995,12 @@ mod tests { }; login_device_ids.push(device_id); + // This test doesn't really care if `last_active_at` is filled in. Ideally, + // we would explicitly test both scenarios (null and filled in) but either + // is fine. + // + // state.activity_tracker.flush().await; + // Advance time so that each session ID sorts deterministically after each // other (ULID includes timestamp). We would have flaky tests without this. state From 777f74be5d7fb36d56a53a4e42cbe9f5d8049a58 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:21:24 -0500 Subject: [PATCH 46/70] Clarify 90d inactive threshold See: - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473863 - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473783 --- crates/handlers/src/compat/login.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 2d3d204d5..803a401f0 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -544,18 +544,19 @@ async fn process_violations_for_compat_login( let lru_compat_sessions = { // TODO: In the future, instead of all of this faff, we can simply order // by `last_active_at` - // - // XXX: Since we can't order by `last_active_at` yet, we instead filter - // the list down to "inactive" sessions (`last_active_at` > 90 days - // ago). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a `Ulid`/`Uuid` - // (the query is ordered by `compat_session_id`), the first bytes are a - // timestamp so we'll be getting the 'oldest created' sessions which is - // another good proxy. let mut edges_to_consider = Vec::new(); // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. let inactive_threshold_date = clock.now() - Duration::days(90); let inactive_compat_session_page = repo .compat_session() From 49dea7ee6115dbbffb0d71c00e6d79f75cbf86b8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:23:12 -0500 Subject: [PATCH 47/70] No hypen grammar See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473792 --- crates/config/src/sections/experimental.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index a7165eed9..2df6cd660 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -195,7 +195,7 @@ impl SessionLimitConfig { // See [`SessionLimitConfig::hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { return Err(figment::error::Error::from( - "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set. \ + "Session `hard_limit` must be at least 2 when automatic `hard_limit_eviction` is set. \ See configuration docs for more info.", ).with_path("hard_limit").into()); } From 8cd3b451becd0bfc550a27998eda4c56d047041c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:23:54 -0500 Subject: [PATCH 48/70] `catastrophically` typo See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473807 --- crates/config/src/sections/experimental.rs | 2 +- docs/config.schema.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 2df6cd660..d9326be63 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -173,7 +173,7 @@ pub struct SessionLimitConfig { /// recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be - /// at-least 2 to avoid catastropically losing encrypted history and digital + /// at-least 2 to avoid catastrophically losing encrypted history and digital /// identity in pathological cases. Keep in mind this is a bare minimum /// restriction and you can still run into trouble. /// diff --git a/docs/config.schema.json b/docs/config.schema.json index 042bbafbd..369be92ba 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,7 +2910,7 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastropically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastrophically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } @@ -2921,4 +2921,4 @@ ] } } -} \ No newline at end of file +} From 82376b5c06b82b6602606d4daa593c21f2e817bd Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:36:23 -0500 Subject: [PATCH 49/70] Placeholder syntax See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473813 --- crates/policy/src/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index d5ad5b632..479b8651a 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -73,7 +73,7 @@ impl ViolationVariant { Self::EmailDomainBanned => "email-domain-banned", Self::EmailNotAllowed => "email-not-allowed", Self::EmailBanned => "email-banned", - Self::TooManySessions { need_to_remove: _ } => "too-many-sessions", + Self::TooManySessions { .. } => "too-many-sessions", } } } From 5458ef9b82b6f1dd14f58d200309a25203a45f6f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:37:12 -0500 Subject: [PATCH 50/70] Expand `need_to_remove` docstring to explain what for See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473819 --- crates/policy/src/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 479b8651a..83ceb08b9 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -53,7 +53,7 @@ pub enum ViolationVariant { /// The user has reached their session limit. TooManySessions { - /// How many devices to remove + /// How many devices need to be removed to make room for the new session need_to_remove: u32, }, } From a92f040da23f15c8c4740fdcaddb5aab55dfdb80 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:51:18 -0500 Subject: [PATCH 51/70] Log removed session ID's See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473830 --- crates/handlers/src/compat/login.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 803a401f0..63ce04f14 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -634,6 +634,25 @@ async fn process_violations_for_compat_login( // Remove the sessions (only as much as necessary, `need_to_remove`) for compat_session in &lru_compat_sessions[0..need_to_remove] { + // Log what's happening so we have some explanation if someone asks + // + // FIXME: In the future, it would probably good to mark the reason + // down in the database for a better paper trail. + tracing::info!( + // So we can easily find logs for a given user + user_id = user.id.to_string(), + username = user.username, + // So we can easily look it up in the MAS database + compat_session_id = compat_session.id.to_string(), + // Make it easier to line up with what the user may be talking about + device_id = compat_session + .device + .as_ref() + .map(mas_data_model::Device::as_str), + "Automatically removing compat session for user (`hard_limit_eviction`)" + ); + + // Remove the session repo.compat_session() .finish(clock, compat_session.to_owned()) .await?; From 4073c41958918ba9fcef843d160fcb5d63765009 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 16:07:01 -0500 Subject: [PATCH 52/70] Fix `havea` -> `have a` typo See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473853 --- crates/handlers/src/compat/login.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 63ce04f14..474efc213 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1928,7 +1928,7 @@ mod tests { .0 .device .clone() - .expect("Expected each login should havea a device") + .expect("Expected each login should have a device") .as_str() .to_owned() }) @@ -2059,7 +2059,7 @@ mod tests { .0 .device .clone() - .expect("Expected each login should havea a device") + .expect("Expected each login should have a device") .as_str() .to_owned() }) From 3d5c3b01a4a7612b36c71b40a4138ae522683c58 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:35:23 -0500 Subject: [PATCH 53/70] Fix session replacement tests --- policies/compat_login/compat_login_test.rego | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 1b8049844..a8d553474 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -84,16 +84,18 @@ test_session_limiting_password if { with data.session_limit as null } +# If the session is replacing an existing session, no need to throw any violations about +# too many sessions test_no_session_limiting_upon_replacement if { - not compat_login.allow with input.user as user + compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} - with input.session_replaced as false + with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - not compat_login.allow with input.user as user + compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} - with input.session_replaced as false + with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} } From 1b96000cedc4a1cc6ccbdea8f94a40c763e1d2e8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:38:49 -0500 Subject: [PATCH 54/70] Use correct limit --- policies/compat_login/compat_login.rego | 2 +- policies/compat_login/compat_login_test.rego | 146 ++++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index dd505f8a5..5f1825268 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -30,7 +30,7 @@ violation contains { "msg": "user has too many active sessions (soft limit)", # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session - "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, + "need_to_remove": (input.session_counts.total - data.session_limit.soft_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index a8d553474..09f18f30c 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -10,92 +10,190 @@ import rego.v1 user := {"username": "john"} +# Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 +# if the `too-many-sessions` violation is not found +get_need_to_remove(violations) := need if { + some v in violations + v.code == "too-many-sessions" + need := v.need_to_remove +} else := 0 + # Tests session limiting when using (the interactive part of) `m.login.sso` -test_session_limiting_sso if { - compat_login.allow with input.user as user +# (interactive, therefore `soft_limit` applies) +# ========================================================================= +test_session_limiting_sso_under_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - compat_login.allow with input.user as user +test_session_limiting_sso_barely_under_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_hit_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_over_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 11 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_over_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + # Only the soft-limit applies to the interactive `m.login.sso` login + result.need_to_remove == 34 +} - # No limit configured - compat_login.allow with input.user as user +test_session_limiting_sso_no_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false + # No limit configured with data.session_limit as null + result.allow + result.need_to_remove == 0 } -# Test session limiting when using `m.login.password` -test_session_limiting_password if { - compat_login.allow with input.user as user +# Test session limiting when using `m.login.password` (not interactive, therefore +# `hard_limit` applies) +# ========================================================================= +test_session_limiting_password_under_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - compat_login.allow with input.user as user +test_session_limiting_password_under_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not compat_login.allow with input.user as user +test_session_limiting_password_hit_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not compat_login.allow with input.user as user +test_session_limiting_password_over_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 2 +} - # No limit configured - compat_login.allow with input.user as user +test_session_limiting_password_no_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false + # No limit configured with data.session_limit as null + result.allow + result.need_to_remove == 0 } # If the session is replacing an existing session, no need to throw any violations about # too many sessions -test_no_session_limiting_upon_replacement if { - compat_login.allow with input.user as user - with input.session_counts as {"total": 65} - with input.login as {"type": "m.login.password"} - with input.session_replaced as true - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - - compat_login.allow with input.user as user +test_no_session_limiting_sso_upon_replacement if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} + +test_no_session_limiting_password_upon_replacement if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.password"} + with input.session_replaced as true + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 } From abe4c3519432f2f2afeb22b92505bbe6717f8342 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:38:57 -0500 Subject: [PATCH 55/70] Add tests for `need_to_remove` See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473825 --- policies/authorization_grant/authorization_grant.rego | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index eca6ccbcf..34334d36b 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -159,7 +159,7 @@ violation contains { "msg": "user has too many active sessions", # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session - "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, + "need_to_remove": (input.session_counts.total - data.session_limit.soft_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null From f30bf47e8208cae593db21ef8563bd01938e454c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:47:01 -0500 Subject: [PATCH 56/70] Add `need_to_remove` policy tests for authorization grant --- .../authorization_grant_test.rego | 79 +++++++++++++++---- policies/compat_login/compat_login_test.rego | 2 +- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index e2ca74086..45b01c676 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -223,34 +223,81 @@ test_mas_scopes if { with input.scope as "urn:mas:admin" } -test_session_limiting if { - authorization_grant.allow with input.user as user +# Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 +# if the `too-many-sessions` violation is not found +get_need_to_remove(violations) := need if { + some v in violations + v.code == "too-many-sessions" + need := v.need_to_remove +} else := 0 + +# Tests session limiting when using OAuth 2.0 authorization grants +# (interactive, therefore `soft_limit` applies) +# ========================================================================= +test_session_limiting_under_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - authorization_grant.allow with input.user as user +test_session_limiting_under_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 31} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not authorization_grant.allow with input.user as user +test_session_limiting_hit_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 32} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not authorization_grant.allow with input.user as user +test_session_limiting_over_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 42} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 11 +} - not authorization_grant.allow with input.user as user +test_session_limiting_over_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - - # No limit configured - authorization_grant.allow with input.user as user - with input.session_counts as {"total": 1} - with data.session_limit as null - - # Client credentials grant - authorization_grant.allow with input.user as user - with input.session_counts as null - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + # Only the `soft_limit` applies to the interactive login + result.need_to_remove == 34 +} + +test_session_limiting_no_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user + with input.session_counts as {"total": 1} + # No limit configured + with data.session_limit as null + result.allow + result.need_to_remove == 0 } diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 09f18f30c..2aee0eaa0 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -83,7 +83,7 @@ test_session_limiting_sso_over_hard_limit if { with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - # Only the soft-limit applies to the interactive `m.login.sso` login + # Only the `soft_limit` applies to the interactive `m.login.sso` login result.need_to_remove == 34 } From 6b59e355834bead9beb3636043ef71b06ba7b73a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 18:09:19 -0500 Subject: [PATCH 57/70] Automatic formatting/linting and more `at least` typos --- crates/config/src/sections/experimental.rs | 6 +++--- crates/handlers/src/compat/login.rs | 4 ++-- docs/config.schema.json | 4 ++-- policies/authorization_grant/authorization_grant_test.rego | 3 ++- policies/compat_login/compat_login_test.rego | 5 +++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index d9326be63..a0882c1cb 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -173,9 +173,9 @@ pub struct SessionLimitConfig { /// recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be - /// at-least 2 to avoid catastrophically losing encrypted history and digital - /// identity in pathological cases. Keep in mind this is a bare minimum - /// restriction and you can still run into trouble. + /// at least 2 to avoid catastrophically losing encrypted history and + /// digital identity in pathological cases. Keep in mind this is a bare + /// minimum restriction and you can still run into trouble. /// /// This is most applicable in scenarios where your homeserver has many /// legacy bots/scripts that login over and over (which ideally should diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 474efc213..a59997d91 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1810,7 +1810,7 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at-least 2 when `hard_limit_eviction` + // Must be at least 2 when `hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test hard_limit_eviction: true, @@ -1968,7 +1968,7 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at-least 2 when `hard_limit_eviction` + // Must be at least 2 when `hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test hard_limit_eviction: true, diff --git a/docs/config.schema.json b/docs/config.schema.json index 369be92ba..ab7692ead 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,7 +2910,7 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastrophically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } @@ -2921,4 +2921,4 @@ ] } } -} +} \ No newline at end of file diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 45b01c676..8a20aedb0 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -286,17 +286,18 @@ test_session_limiting_over_soft_limit if { with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow + # Only the `soft_limit` applies to the interactive login result.need_to_remove == 34 } test_session_limiting_no_limit if { + # No limit configured result := { "allow": authorization_grant.allow, "need_to_remove": get_need_to_remove(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 2aee0eaa0..dfb9c0328 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -83,11 +83,13 @@ test_session_limiting_sso_over_hard_limit if { with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow + # Only the `soft_limit` applies to the interactive `m.login.sso` login result.need_to_remove == 34 } test_session_limiting_sso_no_limit if { + # No limit configured result := { "allow": compat_login.allow, "need_to_remove": get_need_to_remove(compat_login.violation), @@ -95,7 +97,6 @@ test_session_limiting_sso_no_limit if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 @@ -157,6 +158,7 @@ test_session_limiting_password_over_hard_limit if { } test_session_limiting_password_no_limit if { + # No limit configured result := { "allow": compat_login.allow, "need_to_remove": get_need_to_remove(compat_login.violation), @@ -164,7 +166,6 @@ test_session_limiting_password_no_limit if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 From dcf42a842fd6446db53671136f28be4476f78814 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 18:17:26 -0500 Subject: [PATCH 58/70] Fix policy lints --- .../authorization_grant_test.rego | 28 +++++----- policies/compat_login/compat_login_test.rego | 54 +++++++++---------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 8a20aedb0..4bf6442f5 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -225,7 +225,7 @@ test_mas_scopes if { # Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 # if the `too-many-sessions` violation is not found -get_need_to_remove(violations) := need if { +need_to_remove_sessions(violations) := need if { some v in violations v.code == "too-many-sessions" need := v.need_to_remove @@ -237,68 +237,68 @@ get_need_to_remove(violations) := need if { test_session_limiting_under_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_under_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 31} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_hit_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 32} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_over_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 42} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 11 + result.need_to_remove_sessions == 11 } -test_session_limiting_over_soft_limit if { +test_session_limiting_over_hard_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow # Only the `soft_limit` applies to the interactive login - result.need_to_remove == 34 + result.need_to_remove_sessions == 34 } test_session_limiting_no_limit if { # No limit configured result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index dfb9c0328..339b1b684 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -12,7 +12,7 @@ user := {"username": "john"} # Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 # if the `too-many-sessions` violation is not found -get_need_to_remove(violations) := need if { +need_to_remove_sessions(violations) := need if { some v in violations v.code == "too-many-sessions" need := v.need_to_remove @@ -24,59 +24,59 @@ get_need_to_remove(violations) := need if { test_session_limiting_sso_under_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_sso_barely_under_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_sso_hit_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_sso_over_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 11 + result.need_to_remove_sessions == 11 } test_session_limiting_sso_over_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} @@ -85,21 +85,21 @@ test_session_limiting_sso_over_hard_limit if { not result.allow # Only the `soft_limit` applies to the interactive `m.login.sso` login - result.need_to_remove == 34 + result.need_to_remove_sessions == 34 } test_session_limiting_sso_no_limit if { # No limit configured result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } # Test session limiting when using `m.login.password` (not interactive, therefore @@ -108,67 +108,67 @@ test_session_limiting_sso_no_limit if { test_session_limiting_password_under_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_password_under_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_password_hit_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_password_over_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 2 + result.need_to_remove_sessions == 2 } test_session_limiting_password_no_limit if { # No limit configured result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } # If the session is replacing an existing session, no need to throw any violations about @@ -176,25 +176,25 @@ test_session_limiting_password_no_limit if { test_no_session_limiting_sso_upon_replacement if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_no_session_limiting_password_upon_replacement if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } From 3e871eb2840021abbd823216750e4c06709892cd Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 24 Apr 2026 19:03:56 -0500 Subject: [PATCH 59/70] Time always goes forward See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473850 --- crates/handlers/src/compat/login.rs | 68 ++++++++++++++++++----------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index a59997d91..d29506372 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -54,6 +54,9 @@ static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { const TYPE: Key = Key::from_static_str("type"); const RESULT: Key = Key::from_static_str("result"); +/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" sessions. +const INACTIVE_SESSION_THRESHOLD: chrono::TimeDelta = Duration::days(90); + #[derive(Debug, Serialize)] #[serde(tag = "type")] enum LoginType { @@ -557,7 +560,7 @@ async fn process_violations_for_compat_login( // `Ulid` (the query is ordered by `compat_session_id`), the // first bytes are a timestamp so we'll be getting the 'oldest // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - Duration::days(90); + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; let inactive_compat_session_page = repo .compat_session() .list( @@ -930,7 +933,11 @@ async fn user_password_login( #[cfg(test)] mod tests { - use std::{collections::HashSet, num::NonZeroU64, ops::Sub}; + use std::{ + collections::HashSet, + num::NonZeroU64, + ops::{Mul, Sub}, + }; use hyper::Request; use mas_matrix::{HomeserverConnection, ProvisionRequest}; @@ -1831,23 +1838,7 @@ mod tests { let mut login_device_ids: Vec = Vec::new(); - // Keep logging in to add more sessions, up to the `hard_limit`. Then `+ 1` for - // one more login will drop one of our old sessions to make room for the new - // login - #[allow(clippy::range_plus_one)] - for login_index in 0..(session_limit_config.hard_limit.get() + 1) { - let original_time = state.clock.now(); - // All of the logins except the last one should be in the past - if login_index <= session_limit_config.hard_limit.get() { - // Rewind time so the logins appear older than our "inactive" threshold (90 - // days) - let login_index_i64: i64 = login_index.try_into().unwrap(); - state - .clock - // Each login is a day earlier - .advance(Duration::days(-200 + login_index_i64)); - } - + let do_login = async || { let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.password", "identifier": { @@ -1868,16 +1859,34 @@ mod tests { panic!("Expected `device_id` to be a string") } }; - login_device_ids.push(device_id); // Wait for `last_active_at` to be set state.activity_tracker.flush().await; - // Restore time - state.clock.advance(original_time - state.clock.now()); - } + // Return the new device + device_id + }; - // Sanity check that the compat sessions have `last_active_at` set. This is + // Keep logging in to add more sessions, up to the `hard_limit`. + #[allow(clippy::range_plus_one)] + for _ in 0..session_limit_config.hard_limit.get() { + let device_id = do_login().await; + login_device_ids.push(device_id); + + // Advance time so it appears like each login happens a day after each other + state.clock.advance(Duration::days(1)); + } + let time_after_past_logins = state.clock.now(); + + // Jump to "current time" (anything > INACTIVE_SESSION_THRESHOLD) which will + // make all of those past logins to be considered "inactive" at this point. + state.clock.advance(INACTIVE_SESSION_THRESHOLD.mul(2)); + assert!( + state.clock.now() - time_after_past_logins > INACTIVE_SESSION_THRESHOLD, + "Expected 'current time' login to happen > INACTIVE_SESSION_THRESHOLD from when the past logins happened" + ); + + // Sanity check that the past compat sessions have `last_active_at` set. This is // important as `last_active_at` starts out null. let mut repo = state.repository().await.unwrap(); let compat_session_page = repo @@ -1894,11 +1903,18 @@ mod tests { .last_active_at .expect("We expect compat sessions to have `last_active_at` set for this test"); assert!( - last_active_at < (state.clock.now().sub(Duration::days(90))), - "Expected compat sessions to have a `last_active_at` older than the 90 day 'inactive' threshold" + last_active_at < (state.clock.now().sub(INACTIVE_SESSION_THRESHOLD)), + "Expected past compat sessions to have a `last_active_at` older than the `INACTIVE_SESSION_THRESHOLD`" ); } + // Now the user wants to login in the "current time". + // + // One more login will drop one of our old sessions to make room for the new + // login + let device_id = do_login().await; + login_device_ids.push(device_id); + // Ensure we still only have two sessions (`session_limit_config.hard_limit`). // We're sanity checking across all session types. let session_counts = count_user_sessions_for_limiting(&mut repo, &user) From 8ab60954cfe33765926c39a432186ce8c5edf016 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 24 Apr 2026 19:12:58 -0500 Subject: [PATCH 60/70] Rename option `dangerous_hard_limit_eviction` --- crates/cli/src/util.rs | 4 +-- crates/config/src/sections/experimental.rs | 19 +++++------ crates/data-model/src/site_config.rs | 2 +- crates/handlers/src/compat/login.rs | 37 +++++++++++----------- docs/config.schema.json | 6 ++-- 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 2bf35f3c3..c3a8412f9 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -156,7 +156,7 @@ pub async fn policy_factory_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, - hard_limit_eviction: c.hard_limit_eviction, + dangerous_hard_limit_eviction: c.dangerous_hard_limit_eviction, }); let data = mas_policy::Data::new(mas_policy::BaseData { @@ -246,7 +246,7 @@ pub fn site_config_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, - hard_limit_eviction: c.hard_limit_eviction, + dangerous_hard_limit_eviction: c.dangerous_hard_limit_eviction, }), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index a0882c1cb..6806ec760 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -153,13 +153,14 @@ pub struct SessionLimitConfig { /// /// [`hard_limit`]: Self::hard_limit pub soft_limit: NonZeroU64, - /// Upon login, when `hard_limit_eviction: false`, will refuse the new login - /// (policy violation error), otherwise, see [`hard_limit_eviction`]. + /// Upon login, when `dangerous_hard_limit_eviction: false`, will refuse the + /// new login (policy violation error), otherwise, see + /// [`dangerous_hard_limit_eviction`]. /// /// The hard limit is enforced in all contexts /// (interactive/non-interactive). /// - /// [`hard_limit_eviction`]: Self::hard_limit_eviction + /// [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction pub hard_limit: NonZeroU64, /// Whether we should automatically choose the least recently used devices /// to remove when the [`Self::hard_limit`] is reached; in order to @@ -172,7 +173,7 @@ pub struct SessionLimitConfig { /// be recovered if you have another verified active device or have a /// recovery key setup. /// - /// When using [`hard_limit_eviction`], the [`hard_limit`] must be + /// When using [`dangerous_hard_limit_eviction`], the [`hard_limit`] must be /// at least 2 to avoid catastrophically losing encrypted history and /// digital identity in pathological cases. Keep in mind this is a bare /// minimum restriction and you can still run into trouble. @@ -185,17 +186,17 @@ pub struct SessionLimitConfig { /// level of sanity with the number of devices that people can have. /// /// [`hard_limit`]: Self::hard_limit - /// [`hard_limit_eviction`]: Self::hard_limit_eviction + /// [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction #[serde(default = "default_false")] - pub hard_limit_eviction: bool, + pub dangerous_hard_limit_eviction: bool, } impl SessionLimitConfig { fn validate(&self) -> Result<(), Box> { - // See [`SessionLimitConfig::hard_limit_eviction`] docstring - if self.hard_limit_eviction && self.hard_limit.get() < 2 { + // See [`SessionLimitConfig::dangerous_hard_limit_eviction`] docstring + if self.dangerous_hard_limit_eviction && self.hard_limit.get() < 2 { return Err(figment::error::Error::from( - "Session `hard_limit` must be at least 2 when automatic `hard_limit_eviction` is set. \ + "Session `hard_limit` must be at least 2 when automatic `dangerous_hard_limit_eviction` is set. \ See configuration docs for more info.", ).with_path("hard_limit").into()); } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 2c04c2438..9d164c639 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -44,7 +44,7 @@ pub struct SessionExpirationConfig { pub struct SessionLimitConfig { pub soft_limit: NonZeroU64, pub hard_limit: NonZeroU64, - pub hard_limit_eviction: bool, + pub dangerous_hard_limit_eviction: bool, } /// Random site configuration we want accessible in various places. diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index d29506372..20bd7cf1a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -54,7 +54,8 @@ static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { const TYPE: Key = Key::from_static_str("type"); const RESULT: Key = Key::from_static_str("result"); -/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" sessions. +/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" +/// sessions. const INACTIVE_SESSION_THRESHOLD: chrono::TimeDelta = Duration::days(90); #[derive(Debug, Serialize)] @@ -532,9 +533,9 @@ async fn process_violations_for_compat_login( // When logging in with the compatibility API, there is no way for us to // display any web UI for people to remove devices, so we instead - // automatically remove their oldest devices (when `hard_limit_eviction` - // is configured). - if session_limit_config.hard_limit_eviction { + // automatically remove their oldest devices (when + // `dangerous_hard_limit_eviction` is configured). + if session_limit_config.dangerous_hard_limit_eviction { // Find the least recently used (LRU) compat sessions // // In the future, it may be nice to avoid sessions with @@ -652,7 +653,7 @@ async fn process_violations_for_compat_login( .device .as_ref() .map(mas_data_model::Device::as_str), - "Automatically removing compat session for user (`hard_limit_eviction`)" + "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" ); // Remove the session @@ -1705,7 +1706,7 @@ mod tests { soft_limit: NonZeroU64::new(1).unwrap(), // Some arbitrary high value (more than we login) hard_limit: NonZeroU64::new(5).unwrap(), - hard_limit_eviction: false, + dangerous_hard_limit_eviction: false, }), ..test_site_config() }, @@ -1755,7 +1756,7 @@ mod tests { soft_limit: NonZeroU64::new(1).unwrap(), // Lowest non-zero value so we don't have to login a bunch hard_limit: NonZeroU64::new(1).unwrap(), - hard_limit_eviction: false, + dangerous_hard_limit_eviction: false, }), ..test_site_config() }, @@ -1806,10 +1807,10 @@ mod tests { ); } - /// Test that the `hard_limit_eviction` will automatically drop old sessions - /// when we go over the limit + /// Test that the `dangerous_hard_limit_eviction` will automatically drop + /// old sessions when we go over the limit #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_hard_limit_eviction_old_compat_login(pool: PgPool) { + async fn test_dangerous_hard_limit_eviction_old_compat_login(pool: PgPool) { setup(); let state = TestState::from_pool_with_site_config( pool, @@ -1817,10 +1818,10 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at least 2 when `hard_limit_eviction` + // Must be at least 2 when `dangerous_hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test - hard_limit_eviction: true, + dangerous_hard_limit_eviction: true, }), ..test_site_config() }, @@ -1972,11 +1973,11 @@ mod tests { ); } - /// Test that the `hard_limit_eviction` will automatically drop the oldest - /// sessions when we go over the limit even if all of the sessions are - /// recent. + /// Test that the `dangerous_hard_limit_eviction` will automatically drop + /// the oldest sessions when we go over the limit even if all of the + /// sessions are recent. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_hard_limit_eviction_recent_compat_login(pool: PgPool) { + async fn test_dangerous_hard_limit_eviction_recent_compat_login(pool: PgPool) { setup(); let state = TestState::from_pool_with_site_config( pool, @@ -1984,10 +1985,10 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at least 2 when `hard_limit_eviction` + // Must be at least 2 when `dangerous_hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test - hard_limit_eviction: true, + dangerous_hard_limit_eviction: true, }), ..test_site_config() }, diff --git a/docs/config.schema.json b/docs/config.schema.json index ab7692ead..c2c988a60 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2904,13 +2904,13 @@ "minimum": 1 }, "hard_limit": { - "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login\n (policy violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts\n (interactive/non-interactive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Upon login, when `dangerous_hard_limit_eviction: false`, will refuse the\n new login (policy violation error), otherwise, see\n [`dangerous_hard_limit_eviction`].\n\n The hard limit is enforced in all contexts\n (interactive/non-interactive).\n\n [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction", "type": "integer", "format": "uint64", "minimum": 1 }, - "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "dangerous_hard_limit_eviction": { + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`dangerous_hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction", "type": "boolean", "default": false } From 4f660bd9eaee81be9399dd154eed67063722a477 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:13:54 -0500 Subject: [PATCH 61/70] Remove too-tight assertion around `session_limit` config when encountering violation See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473839 --- crates/handlers/src/compat/login.rs | 287 +++++++++++++++------------- 1 file changed, 152 insertions(+), 135 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 20bd7cf1a..1020f377b 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -521,149 +521,166 @@ async fn process_violations_for_compat_login( .. }, ] => { - let session_limit_config = session_limit_config - .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ - This is most likely a programming error."); - - let need_to_remove = usize::try_from(*need_to_remove).map_err(|err| { - RouteError::Internal( - anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}").into(), - ) - })?; - - // When logging in with the compatibility API, there is no way for us to - // display any web UI for people to remove devices, so we instead - // automatically remove their oldest devices (when - // `dangerous_hard_limit_eviction` is configured). - if session_limit_config.dangerous_hard_limit_eviction { - // Find the least recently used (LRU) compat sessions - // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). - // - // FIXME: We could potentially use - // `repo.compat_session().finish_bulk(...)` if it had the ability to - // limit and order. - let lru_compat_sessions = { - // TODO: In the future, instead of all of this faff, we can simply order - // by `last_active_at` - - let mut edges_to_consider = Vec::new(); - - // First, find the "inactive" sessions - // - // XXX: Since we can't order by `last_active_at` yet, we instead - // filter the list down to "inactive" sessions (`last_active_at` > - // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the - // web UI for "inactive" sessions). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a - // `Ulid` (the query is ordered by `compat_session_id`), the - // first bytes are a timestamp so we'll be getting the 'oldest - // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; - let inactive_compat_session_page = repo - .compat_session() - .list( - CompatSessionFilter::new() - .for_user(user) - .active_only() - .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), + // Normally, if we are seeing a `TooManySessions` violation, we would + // expect `session_limit_config` to be filled in but if someone created + // their own policies which emit a `TooManySessions` violation that isn't + // based on the configured `session_limit`, we could also end up here. + // + // If you're using the default policies in MAS, `session_limit_config` being + // `None` would be a programming error. + match session_limit_config { + Some(session_limit_config) => { + let need_to_remove = usize::try_from(*need_to_remove).map_err(|err| { + RouteError::Internal( + anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}") + .into(), ) - .await?; - edges_to_consider.extend(inactive_compat_session_page.edges); + })?; - // If there aren't enough "inactive" sessions, supplement with active ones - if edges_to_consider.len() < need_to_remove { - let active_compat_session_page = repo - .compat_session() - .list( - // If we try to use - // `.with_last_active_after(inactive_threshold_date)` - // here, it will exclude all of the rows where - // `last_active_at` is null which we want to include. - CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) - .await?; - edges_to_consider.extend(active_compat_session_page.edges); - } + // When logging in with the compatibility API, there is no way for us to + // display any web UI for people to remove devices, so we instead + // automatically remove their oldest devices (when + // `dangerous_hard_limit_eviction` is configured). + if session_limit_config.dangerous_hard_limit_eviction { + // Find the least recently used (LRU) compat sessions + // + // In the future, it may be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys uploaded + // for device?). + // + // FIXME: We could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the ability to + // limit and order. + let lru_compat_sessions = { + // TODO: In the future, instead of all of this faff, we can simply order + // by `last_active_at` - // De-duplicate the sessions across both pages - let compat_session_map = { - let mut compat_session_map = HashMap::new(); - for edge in edges_to_consider { - let (compat_session, _) = edge.node; - compat_session_map.insert(compat_session.id, compat_session); + let mut edges_to_consider = Vec::new(); + + // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; + let inactive_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_before(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove, 100)), + ) + .await?; + edges_to_consider.extend(inactive_compat_session_page.edges); + + // If there aren't enough "inactive" sessions, supplement with active ones + if edges_to_consider.len() < need_to_remove { + let active_compat_session_page = repo + .compat_session() + .list( + // If we try to use + // `.with_last_active_after(inactive_threshold_date)` + // here, it will exclude all of the rows where + // `last_active_at` is null which we want to include. + CompatSessionFilter::new().for_user(user).active_only(), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove, 100)), + ) + .await?; + edges_to_consider.extend(active_compat_session_page.edges); + } + + // De-duplicate the sessions across both pages + let compat_session_map = { + let mut compat_session_map = HashMap::new(); + for edge in edges_to_consider { + let (compat_session, _) = edge.node; + compat_session_map.insert(compat_session.id, compat_session); + } + compat_session_map + }; + + // List of compat sessions sorted by `last_active_at` ascending + let sorted_compat_sessions = { + let mut compat_sessions: Vec = + compat_session_map.into_values().collect(); + // Sort by `last_active_at` (ascending) + compat_sessions.sort_by_key(|compat_session| { + ( + // We mainly care about sorting by `last_active_at` + compat_session.last_active_at, + // Tie-break based on `created_at` + compat_session.created_at, + // Tie-break based on `id` for determinism + compat_session.id, + ) + }); + compat_sessions + }; + + sorted_compat_sessions + }; + + // For now, we only automatically clean up compatibility sessions. + // If there aren't enough sessions that we could clean up, we just + // throw an error with an explanation. + if lru_compat_sessions.len() < need_to_remove { + return Err(RouteError::PolicyHardSessionLimitReached); } - compat_session_map - }; - // List of compat sessions sorted by `last_active_at` ascending - let sorted_compat_sessions = { - let mut compat_sessions: Vec = - compat_session_map.into_values().collect(); - // Sort by `last_active_at` (ascending) - compat_sessions.sort_by_key(|compat_session| { - ( - // We mainly care about sorting by `last_active_at` - compat_session.last_active_at, - // Tie-break based on `created_at` - compat_session.created_at, - // Tie-break based on `id` for determinism - compat_session.id, - ) - }); - compat_sessions - }; + // Remove the sessions (only as much as necessary, `need_to_remove`) + for compat_session in &lru_compat_sessions[0..need_to_remove] { + // Log what's happening so we have some explanation if someone asks + // + // FIXME: In the future, it would probably good to mark the reason + // down in the database for a better paper trail. + tracing::info!( + // So we can easily find logs for a given user + user_id = user.id.to_string(), + username = user.username, + // So we can easily look it up in the MAS database + compat_session_id = compat_session.id.to_string(), + // Make it easier to line up with what the user may be talking about + device_id = compat_session + .device + .as_ref() + .map(mas_data_model::Device::as_str), + "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" + ); - sorted_compat_sessions - }; - - // For now, we only automatically clean up compatibility sessions. - // If there aren't enough sessions that we could clean up, we just - // throw an error with an explanation. - if lru_compat_sessions.len() < need_to_remove { - return Err(RouteError::PolicyHardSessionLimitReached); + // Remove the session + repo.compat_session() + .finish(clock, compat_session.to_owned()) + .await?; + } + } else { + // Tell the user about the limit + return Err(RouteError::PolicyHardSessionLimitReached); + } } - - // Remove the sessions (only as much as necessary, `need_to_remove`) - for compat_session in &lru_compat_sessions[0..need_to_remove] { - // Log what's happening so we have some explanation if someone asks - // - // FIXME: In the future, it would probably good to mark the reason - // down in the database for a better paper trail. - tracing::info!( - // So we can easily find logs for a given user - user_id = user.id.to_string(), - username = user.username, - // So we can easily look it up in the MAS database - compat_session_id = compat_session.id.to_string(), - // Make it easier to line up with what the user may be talking about - device_id = compat_session - .device - .as_ref() - .map(mas_data_model::Device::as_str), - "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" - ); - - // Remove the session - repo.compat_session() - .finish(clock, compat_session.to_owned()) - .await?; + // If we got here, it means they are using their own custom policies + // which don't take into account the configured `session_limit`. + // + // We don't know the actual reason behind the policy emitting the + // violation so we just have to show a generic policy rejected page. + None => { + // FIXME: We should be exposing the violations to the user + return Err(RouteError::PolicyRejected); } - } else { - // Tell the user about the limit - return Err(RouteError::PolicyHardSessionLimitReached); } } // Nothing is wrong From 9506832343a80b93da8f1a9d80223494f00ffc46 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:35:26 -0500 Subject: [PATCH 62/70] Extract logic to `find_lru_compat_sessions_flawed(...)` to make the usage more clear --- crates/handlers/src/compat/login.rs | 191 +++++++++++++++------------- 1 file changed, 103 insertions(+), 88 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1020f377b..7d295f641 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -544,96 +544,16 @@ async fn process_violations_for_compat_login( if session_limit_config.dangerous_hard_limit_eviction { // Find the least recently used (LRU) compat sessions // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). + // FIXME: In the future, it would be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys + // uploaded for device?). // - // FIXME: We could potentially use - // `repo.compat_session().finish_bulk(...)` if it had the ability to - // limit and order. - let lru_compat_sessions = { - // TODO: In the future, instead of all of this faff, we can simply order - // by `last_active_at` - - let mut edges_to_consider = Vec::new(); - - // First, find the "inactive" sessions - // - // XXX: Since we can't order by `last_active_at` yet, we instead - // filter the list down to "inactive" sessions (`last_active_at` > - // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the - // web UI for "inactive" sessions). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a - // `Ulid` (the query is ordered by `compat_session_id`), the - // first bytes are a timestamp so we'll be getting the 'oldest - // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; - let inactive_compat_session_page = repo - .compat_session() - .list( - CompatSessionFilter::new() - .for_user(user) - .active_only() - .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) + // FIXME: Instead of finding, then finshing, we could + // potentially use `repo.compat_session().finish_bulk(...)` if + // it had the ability to limit and order. + let lru_compat_sessions = + find_lru_compat_sessions_flawed(clock, repo, user, need_to_remove) .await?; - edges_to_consider.extend(inactive_compat_session_page.edges); - - // If there aren't enough "inactive" sessions, supplement with active ones - if edges_to_consider.len() < need_to_remove { - let active_compat_session_page = repo - .compat_session() - .list( - // If we try to use - // `.with_last_active_after(inactive_threshold_date)` - // here, it will exclude all of the rows where - // `last_active_at` is null which we want to include. - CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) - .await?; - edges_to_consider.extend(active_compat_session_page.edges); - } - - // De-duplicate the sessions across both pages - let compat_session_map = { - let mut compat_session_map = HashMap::new(); - for edge in edges_to_consider { - let (compat_session, _) = edge.node; - compat_session_map.insert(compat_session.id, compat_session); - } - compat_session_map - }; - - // List of compat sessions sorted by `last_active_at` ascending - let sorted_compat_sessions = { - let mut compat_sessions: Vec = - compat_session_map.into_values().collect(); - // Sort by `last_active_at` (ascending) - compat_sessions.sort_by_key(|compat_session| { - ( - // We mainly care about sorting by `last_active_at` - compat_session.last_active_at, - // Tie-break based on `created_at` - compat_session.created_at, - // Tie-break based on `id` for determinism - compat_session.id, - ) - }); - compat_sessions - }; - - sorted_compat_sessions - }; // For now, we only automatically clean up compatibility sessions. // If there aren't enough sessions that we could clean up, we just @@ -695,6 +615,101 @@ async fn process_violations_for_compat_login( Ok(()) } +/// Find the least recently used (LRU) compat sessions +/// +/// The results of this function are flawed because we can't order by `last_active_at` +/// and get an absolute sort of actually least recently used sessions. But we do a +/// pretty good job at working around the problem (see internal comments for details). +async fn find_lru_compat_sessions_flawed( + clock: &dyn Clock, + repo: &mut BoxRepository, + user: &User, + // Like a limit we this function may return more more results + num_requested: usize, +) -> Result, RouteError> { + // TODO: In the future, instead of all of this faff, we can simply order + // by `last_active_at` + + let mut edges_to_consider = Vec::new(); + + // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; + let inactive_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_before(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(num_requested, 100)), + ) + .await?; + edges_to_consider.extend(inactive_compat_session_page.edges); + + // If there aren't enough "inactive" sessions, supplement with active ones + if edges_to_consider.len() < num_requested { + let active_compat_session_page = repo + .compat_session() + .list( + // If we try to use + // `.with_last_active_after(inactive_threshold_date)` + // here, it will exclude all of the rows where + // `last_active_at` is null which we want to include. + CompatSessionFilter::new().for_user(user).active_only(), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(num_requested, 100)), + ) + .await?; + edges_to_consider.extend(active_compat_session_page.edges); + } + + // De-duplicate the sessions across both pages + let compat_session_map = { + let mut compat_session_map = HashMap::new(); + for edge in edges_to_consider { + let (compat_session, _) = edge.node; + compat_session_map.insert(compat_session.id, compat_session); + } + compat_session_map + }; + + // List of compat sessions sorted by `last_active_at` ascending + let sorted_compat_sessions = { + let mut compat_sessions: Vec = + compat_session_map.into_values().collect(); + // Sort by `last_active_at` (ascending) + compat_sessions.sort_by_key(|compat_session| { + ( + // We mainly care about sorting by `last_active_at` + compat_session.last_active_at, + // Tie-break based on `created_at` + compat_session.created_at, + // Tie-break based on `id` for determinism + compat_session.id, + ) + }); + compat_sessions + }; + + Ok(sorted_compat_sessions) +} + async fn token_login( rng: &mut (dyn RngCore + Send), clock: &dyn Clock, From f17d9233a9bb66f1c7479320e9837b95f1463506 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:38:56 -0500 Subject: [PATCH 63/70] Add spec reference for 'device identity key' (cryptographic state/devices) See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473844 --- crates/handlers/src/compat/login.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 7d295f641..1c8e4756c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -545,12 +545,20 @@ async fn process_violations_for_compat_login( // Find the least recently used (LRU) compat sessions // // FIXME: In the future, it would be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys - // uploaded for device?). + // cryptographic state. What does that mean exactly? Keys + // uploaded for device? The spec says this: + // > For all intents and purposes, non-cryptographic devices are + // > a completely separate concept and do not exist from the + // > perspective of the cryptography layer since they do not + // > have [device] identity keys, so it is impossible to send + // > them decryption keys. + // > + // > -- https://spec.matrix.org/v1.18/client-server-api/#recommended-client-behaviour // - // FIXME: Instead of finding, then finshing, we could - // potentially use `repo.compat_session().finish_bulk(...)` if - // it had the ability to limit and order. + // FIXME: Instead of finding, then finishing in separate steps, + // we could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the + // ability to limit and order. let lru_compat_sessions = find_lru_compat_sessions_flawed(clock, repo, user, need_to_remove) .await?; From 2c716b638ac1efe29733289d9c7d7decf3dd53f8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:59:00 -0500 Subject: [PATCH 64/70] Explain `minimum_sessions_to_fetch` logic See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473846 --- crates/handlers/src/compat/login.rs | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1c8e4756c..3c8b51eb2 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -625,9 +625,10 @@ async fn process_violations_for_compat_login( /// Find the least recently used (LRU) compat sessions /// -/// The results of this function are flawed because we can't order by `last_active_at` -/// and get an absolute sort of actually least recently used sessions. But we do a -/// pretty good job at working around the problem (see internal comments for details). +/// The results of this function are flawed (for accounts with more sessions than +/// `minimum_sessions_to_fetch`) because we can't order by `last_active_at` and get an +/// absolute sort of actually least recently used sessions. But we do a pretty good job +/// at working around the problem (see internal comments for details). async fn find_lru_compat_sessions_flawed( clock: &dyn Clock, repo: &mut BoxRepository, @@ -640,6 +641,21 @@ async fn find_lru_compat_sessions_flawed( let mut edges_to_consider = Vec::new(); + // We fetch a minimum of 2000 sessions (more than we need in normal cases) so we can + // sort by `last_active_at` after it gets back from the database and can get even + // closer to removing the true oldest sessions. + // + // The 2000 number was chosen based on < 0.001% of people on matrix.org having less + // than 2000 sessions and reasoning how much memory is reasonable to spend on this + // operation to get things right. Assuming each row is ~1 KiB (pessimistic high + // bound, see next paragraph below) we end up at 2 MiB of memory. + // + // Each item in the page is `(CompatSession, Option)` where + // `CompatSession` is 192 bytes plus a couple of strings (device name and user + // agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also + // 192 bytes with a `login_token` string which should be no more than 32 bytes. + let minimum_sessions_to_fetch = 2000; + // First, find the "inactive" sessions // // XXX: Since we can't order by `last_active_at` yet, we instead @@ -658,11 +674,7 @@ async fn find_lru_compat_sessions_flawed( .for_user(user) .active_only() .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(num_requested, 100)), + Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), ) .await?; edges_to_consider.extend(inactive_compat_session_page.edges); @@ -677,11 +689,7 @@ async fn find_lru_compat_sessions_flawed( // here, it will exclude all of the rows where // `last_active_at` is null which we want to include. CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(num_requested, 100)), + Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), ) .await?; edges_to_consider.extend(active_compat_session_page.edges); From 701da035f2b04ebbe04c799945eb91952abb6a87 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 16:45:03 -0500 Subject: [PATCH 65/70] `MINIMUM_SESSIONS_TO_FETCH` as 2160 to accomodate script that runs each hour for the 90 day inactive threshold --- crates/handlers/src/compat/login.rs | 48 +++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 3c8b51eb2..475754095 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -623,6 +623,35 @@ async fn process_violations_for_compat_login( Ok(()) } +/// We fetch a minimum number of sessions (2160, more than we need in normal +/// cases) so we can sort by `last_active_at` after it gets back from the database +/// and can get even closer to removing the true oldest sessions. +/// +/// The 2160 number was chosen based on someone having a script that runs every hour +/// for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also aligns +/// nicely with < 0.001% of people on matrix.org having less than 2160 sessions and +/// reasoning how much memory is reasonable to spend on this operation to get things +/// right. Assuming each row is ~1 KiB (pessimistic high bound, see next paragraph +/// below) we end up at ~2 MiB of memory. +/// +/// Each item in the page is `(CompatSession, Option)` where +/// `CompatSession` is 192 bytes plus a couple of strings (device name and user +/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also +/// 192 bytes with a `login_token` string which should be no more than 32 bytes. +const MINIMUM_SESSIONS_TO_FETCH: usize = { + let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; + // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const + // contexts. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { + assert!( + min_sessions >= 0, + "`INACTIVE_SESSION_THRESHOLD` must be non-negative (we want to convert to a usize)" + ); + min_sessions as usize + } +}; + /// Find the least recently used (LRU) compat sessions /// /// The results of this function are flawed (for accounts with more sessions than @@ -641,21 +670,6 @@ async fn find_lru_compat_sessions_flawed( let mut edges_to_consider = Vec::new(); - // We fetch a minimum of 2000 sessions (more than we need in normal cases) so we can - // sort by `last_active_at` after it gets back from the database and can get even - // closer to removing the true oldest sessions. - // - // The 2000 number was chosen based on < 0.001% of people on matrix.org having less - // than 2000 sessions and reasoning how much memory is reasonable to spend on this - // operation to get things right. Assuming each row is ~1 KiB (pessimistic high - // bound, see next paragraph below) we end up at 2 MiB of memory. - // - // Each item in the page is `(CompatSession, Option)` where - // `CompatSession` is 192 bytes plus a couple of strings (device name and user - // agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also - // 192 bytes with a `login_token` string which should be no more than 32 bytes. - let minimum_sessions_to_fetch = 2000; - // First, find the "inactive" sessions // // XXX: Since we can't order by `last_active_at` yet, we instead @@ -674,7 +688,7 @@ async fn find_lru_compat_sessions_flawed( .for_user(user) .active_only() .with_last_active_before(inactive_threshold_date), - Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), + Pagination::first(std::cmp::max(num_requested, MINIMUM_SESSIONS_TO_FETCH)), ) .await?; edges_to_consider.extend(inactive_compat_session_page.edges); @@ -689,7 +703,7 @@ async fn find_lru_compat_sessions_flawed( // here, it will exclude all of the rows where // `last_active_at` is null which we want to include. CompatSessionFilter::new().for_user(user).active_only(), - Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), + Pagination::first(std::cmp::max(num_requested, MINIMUM_SESSIONS_TO_FETCH)), ) .await?; edges_to_consider.extend(active_compat_session_page.edges); From dacbf902242effe5630de5ada1ca8ff4c65e4aba Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:00:37 -0500 Subject: [PATCH 66/70] Also const assert how big `MINIMUM_SESSIONS_TO_FETCH` can be --- crates/handlers/src/compat/login.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 475754095..eef42d41c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -651,6 +651,17 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { min_sessions as usize } }; +// This is a stop-gap to make people think about the downstream effects of updating +// `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go into +// `MINIMUM_SESSIONS_TO_FETCH`. +const _: () = { + assert!( + // Update this value if you're ok with the ammount of memory that could be used. + MINIMUM_SESSIONS_TO_FETCH == 2160, + "Sanity check that you're okay with `MINIMUM_SESSIONS_TO_FETCH` x 1 KiB when fetching sessions? \ + (read the `MINIMUM_SESSIONS_TO_FETCH` docstring)" + ); +}; /// Find the least recently used (LRU) compat sessions /// From b2d7ef9583bdcd739275e65365cdcd5100701bc3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:44:40 -0500 Subject: [PATCH 67/70] Better clarify `MINIMUM_SESSIONS_TO_FETCH` asserts --- crates/handlers/src/compat/login.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index eef42d41c..79ba7541a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -642,12 +642,18 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const // contexts. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] { + // Sanity check that `clippy::cast_sign_loss` doesn't apply assert!( min_sessions >= 0, - "`INACTIVE_SESSION_THRESHOLD` must be non-negative (we want to convert to a usize)" + "`MINIMUM_SESSIONS_TO_FETCH` must be non-negative (we want to convert to a usize)" ); + // For `clippy::cast_possible_truncation`, we're going to assume that someone + // doesn't specify some value bigger than can fit in the `usize`. On a 16-bit + // platform, that would be 65,535 days. + + // Based on the above asserts, we can assume that that the cast is safe min_sessions as usize } }; From caf3d97f5ee94faaea0ccb57c2d9436b7e2b35ca Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:46:28 -0500 Subject: [PATCH 68/70] Fix lints --- crates/handlers/src/compat/login.rs | 43 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 79ba7541a..5c4e72105 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -582,7 +582,8 @@ async fn process_violations_for_compat_login( username = user.username, // So we can easily look it up in the MAS database compat_session_id = compat_session.id.to_string(), - // Make it easier to line up with what the user may be talking about + // Make it easier to line up with what the user may be talking + // about device_id = compat_session .device .as_ref() @@ -624,24 +625,25 @@ async fn process_violations_for_compat_login( } /// We fetch a minimum number of sessions (2160, more than we need in normal -/// cases) so we can sort by `last_active_at` after it gets back from the database -/// and can get even closer to removing the true oldest sessions. +/// cases) so we can sort by `last_active_at` after it gets back from the +/// database and can get even closer to removing the true oldest sessions. /// -/// The 2160 number was chosen based on someone having a script that runs every hour -/// for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also aligns -/// nicely with < 0.001% of people on matrix.org having less than 2160 sessions and -/// reasoning how much memory is reasonable to spend on this operation to get things -/// right. Assuming each row is ~1 KiB (pessimistic high bound, see next paragraph -/// below) we end up at ~2 MiB of memory. +/// The 2160 number was chosen based on someone having a script that runs every +/// hour for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also +/// aligns nicely with < 0.001% of people on matrix.org having less than 2160 +/// sessions and reasoning how much memory is reasonable to spend on this +/// operation to get things right. Assuming each row is ~1 KiB (pessimistic high +/// bound, see next paragraph below) we end up at ~2 MiB of memory. /// /// Each item in the page is `(CompatSession, Option)` where /// `CompatSession` is 192 bytes plus a couple of strings (device name and user -/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also -/// 192 bytes with a `login_token` string which should be no more than 32 bytes. +/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is +/// also 192 bytes with a `login_token` string which should be no more than 32 +/// bytes. const MINIMUM_SESSIONS_TO_FETCH: usize = { let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; - // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const - // contexts. + // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in + // const contexts. #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] { // Sanity check that `clippy::cast_sign_loss` doesn't apply @@ -657,9 +659,9 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { min_sessions as usize } }; -// This is a stop-gap to make people think about the downstream effects of updating -// `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go into -// `MINIMUM_SESSIONS_TO_FETCH`. +// This is a stop-gap to make people think about the downstream effects of +// updating `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go +// into `MINIMUM_SESSIONS_TO_FETCH`. const _: () = { assert!( // Update this value if you're ok with the ammount of memory that could be used. @@ -671,10 +673,11 @@ const _: () = { /// Find the least recently used (LRU) compat sessions /// -/// The results of this function are flawed (for accounts with more sessions than -/// `minimum_sessions_to_fetch`) because we can't order by `last_active_at` and get an -/// absolute sort of actually least recently used sessions. But we do a pretty good job -/// at working around the problem (see internal comments for details). +/// The results of this function are flawed (for accounts with more sessions +/// than `minimum_sessions_to_fetch`) because we can't order by `last_active_at` +/// and get an absolute sort of actually least recently used sessions. But we do +/// a pretty good job at working around the problem (see internal comments for +/// details). async fn find_lru_compat_sessions_flawed( clock: &dyn Clock, repo: &mut BoxRepository, From 2c80015fc99c1d9f88410aee2b624e40d04e5176 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:53:32 -0500 Subject: [PATCH 69/70] Remove `MINIMUM_SESSIONS_TO_FETCH` complexity See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3150594429 --- crates/handlers/src/compat/login.rs | 31 +---------------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5c4e72105..6b0d26814 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -640,36 +640,7 @@ async fn process_violations_for_compat_login( /// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is /// also 192 bytes with a `login_token` string which should be no more than 32 /// bytes. -const MINIMUM_SESSIONS_TO_FETCH: usize = { - let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; - // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in - // const contexts. - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - { - // Sanity check that `clippy::cast_sign_loss` doesn't apply - assert!( - min_sessions >= 0, - "`MINIMUM_SESSIONS_TO_FETCH` must be non-negative (we want to convert to a usize)" - ); - // For `clippy::cast_possible_truncation`, we're going to assume that someone - // doesn't specify some value bigger than can fit in the `usize`. On a 16-bit - // platform, that would be 65,535 days. - - // Based on the above asserts, we can assume that that the cast is safe - min_sessions as usize - } -}; -// This is a stop-gap to make people think about the downstream effects of -// updating `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go -// into `MINIMUM_SESSIONS_TO_FETCH`. -const _: () = { - assert!( - // Update this value if you're ok with the ammount of memory that could be used. - MINIMUM_SESSIONS_TO_FETCH == 2160, - "Sanity check that you're okay with `MINIMUM_SESSIONS_TO_FETCH` x 1 KiB when fetching sessions? \ - (read the `MINIMUM_SESSIONS_TO_FETCH` docstring)" - ); -}; +const MINIMUM_SESSIONS_TO_FETCH: usize = 2160; /// Find the least recently used (LRU) compat sessions /// From e9165887a1414971812c21338df81d86b5692972 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:53:56 -0500 Subject: [PATCH 70/70] Reference actual const in comment --- crates/handlers/src/compat/login.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 6b0d26814..e36e57a5b 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -645,7 +645,7 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = 2160; /// Find the least recently used (LRU) compat sessions /// /// The results of this function are flawed (for accounts with more sessions -/// than `minimum_sessions_to_fetch`) because we can't order by `last_active_at` +/// than `MINIMUM_SESSIONS_TO_FETCH`) because we can't order by `last_active_at` /// and get an absolute sort of actually least recently used sessions. But we do /// a pretty good job at working around the problem (see internal comments for /// details).