diff --git a/crates/policy/src/bin/schema.rs b/crates/policy/src/bin/schema.rs index 8e9c81a07..be778f6e1 100644 --- a/crates/policy/src/bin/schema.rs +++ b/crates/policy/src/bin/schema.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use mas_policy::model::{ - AuthorizationGrantInput, ClientRegistrationInput, EmailInput, RegisterInput, + AuthorizationGrantInput, ClientRegistrationInput, CompatLoginInput, EmailInput, RegisterInput, }; use schemars::{JsonSchema, generate::SchemaSettings}; @@ -42,5 +42,6 @@ fn main() { write_schema::(output_root, "register_input.json"); write_schema::(output_root, "client_registration_input.json"); write_schema::(output_root, "authorization_grant_input.json"); + write_schema::(output_root, "compat_login_input.json"); write_schema::(output_root, "email_input.json"); } diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index b85170025..85b05d317 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -187,6 +187,32 @@ pub struct AuthorizationGrantInput<'a> { pub requester: Requester, } +/// Input for the compatibility login policy. +#[derive(Serialize, Debug, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct CompatLoginInput<'a> { + #[schemars(with = "std::collections::HashMap")] + pub user: &'a User, + + /// How many sessions the user has. + pub session_counts: SessionCounts, + + // TODO is this actually what we care about? Don't we care a bit more about whether we're in an + // interactive context or a non-interactive context? (SSO type has both phases :() + pub login_type: CompatLoginType, + + pub requester: Requester, +} + +#[derive(Serialize, Debug, JsonSchema)] +pub enum CompatLoginType { + #[serde(rename = "m.login.sso")] + WebSso, + + #[serde(rename = "m.login.password")] + Password, +} + /// Information about how many sessions the user has #[derive(Serialize, Debug, JsonSchema)] pub struct SessionCounts { diff --git a/policies/Makefile b/policies/Makefile index 0d515b904..db5991672 100644 --- a/policies/Makefile +++ b/policies/Makefile @@ -16,6 +16,7 @@ INPUTS := \ client_registration/client_registration.rego \ register/register.rego \ authorization_grant/authorization_grant.rego \ + compat_login/compat_login.rego \ email/email.rego ifeq ($(DOCKER), 1) @@ -38,6 +39,7 @@ policy.wasm: $(INPUTS) -e "client_registration/violation" \ -e "register/violation" \ -e "authorization_grant/violation" \ + -e "compat_login/violation" \ -e "email/violation" \ $^ tar xzf bundle.tar.gz /policy.wasm diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego new file mode 100644 index 000000000..3aeb566cf --- /dev/null +++ b/policies/compat_login/compat_login.rego @@ -0,0 +1,61 @@ +# Copyright 2025 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + +# METADATA +# schemas: +# - input: schema["compat_login_input"] +package compat_login + +import rego.v1 + +import data.common + +default allow := false + +allow if { + count(violation) == 0 +} + +violation contains {"msg": sprintf( + "Requester [%s] isn't allowed to do this action", + [common.format_requester(input.requester)], +)} if { + common.requester_banned(input.requester, data.requester) +} + +violation contains { + "code": "too-many-sessions", + "msg": "user has too many active sessions (soft limit)", +} if { + # Only apply if session limits are enabled in the config + data.session_limit != null + + # This is a web-based interactive login + # TODO not strictly correct... + input.login_type == "m.login.sso" + + # For web-based 'compat SSO' login, a violation occurs when the soft limit has already been + # 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 +} + +violation contains { + "code": "too-many-sessions", + "msg": "user has too many active sessions (hard limit)", +} if { + # Only apply if session limits are enabled in the config + data.session_limit != null + + # This is not a web-based interactive login + input.login_type == "m.login.password" + + # For `m.login.password` login, a violation occurs when the hard limit has already been + # 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 +} diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego new file mode 100644 index 000000000..46ec1b0a2 --- /dev/null +++ b/policies/compat_login/compat_login_test.rego @@ -0,0 +1,72 @@ +# Copyright 2025 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + +package compat_login_test + +import data.compat_login +import rego.v1 + +user := {"username": "john"} + +test_session_limiting_sso if { + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login_type as "m.login.sso" + 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_type as "m.login.sso" + 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_type as "m.login.sso" + 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_type as "m.login.sso" + 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_type as "m.login.sso" + 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_type as "m.login.sso" + with data.session_limit as null +} + +test_session_limiting_password if { + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login_type as "m.login.password" + 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_type as "m.login.password" + 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_type as "m.login.password" + 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_type as "m.login.password" + 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_type as "m.login.password" + with data.session_limit as null +} diff --git a/policies/schema/compat_login_input.json b/policies/schema/compat_login_input.json new file mode 100644 index 000000000..c28c79388 --- /dev/null +++ b/policies/schema/compat_login_input.json @@ -0,0 +1,88 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CompatLoginInput", + "description": "Input for the compatibility login policy.", + "type": "object", + "required": [ + "login_type", + "requester", + "session_counts", + "user" + ], + "properties": { + "user": { + "type": "object", + "additionalProperties": true + }, + "session_counts": { + "description": "How many sessions the user has.", + "allOf": [ + { + "$ref": "#/definitions/SessionCounts" + } + ] + }, + "login_type": { + "$ref": "#/definitions/CompatLoginType" + }, + "requester": { + "$ref": "#/definitions/Requester" + } + }, + "definitions": { + "SessionCounts": { + "description": "Information about how many sessions the user has", + "type": "object", + "required": [ + "compat", + "oauth2", + "personal", + "total" + ], + "properties": { + "total": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "oauth2": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "compat": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "personal": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "CompatLoginType": { + "type": "string", + "enum": [ + "m.login.sso", + "m.login.password" + ] + }, + "Requester": { + "description": "Identity of the requester", + "type": "object", + "properties": { + "ip_address": { + "description": "IP address of the entity making the request", + "type": "string", + "format": "ip" + }, + "user_agent": { + "description": "User agent of the entity making the request", + "type": "string" + } + } + } + } +} \ No newline at end of file