diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs
index 83ceb08b9..e408c63c3 100644
--- a/crates/policy/src/model.rs
+++ b/crates/policy/src/model.rs
@@ -1,3 +1,4 @@
+// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
@@ -51,6 +52,12 @@ pub enum ViolationVariant {
/// The email address is banned.
EmailBanned,
+ /// An admin scope was requested but is not allowed.
+ AdminScopeNotAllowed,
+
+ /// The client is not allowed by the policy.
+ ClientNotAllowed,
+
/// The user has reached their session limit.
TooManySessions {
/// How many devices need to be removed to make room for the new session
@@ -73,6 +80,8 @@ impl ViolationVariant {
Self::EmailDomainBanned => "email-domain-banned",
Self::EmailNotAllowed => "email-not-allowed",
Self::EmailBanned => "email-banned",
+ Self::AdminScopeNotAllowed => "admin-scope-not-allowed",
+ Self::ClientNotAllowed => "client-not-allowed",
Self::TooManySessions { .. } => "too-many-sessions",
}
}
diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs
index 6148cc165..937a2b66e 100644
--- a/crates/templates/src/context.rs
+++ b/crates/templates/src/context.rs
@@ -842,7 +842,7 @@ impl TemplateContext for PolicyViolationContext {
let authorization_grant_invalid_scope =
PolicyViolationContext::for_authorization_grant(
- grant,
+ grant.clone(),
client.clone(),
vec![Violation {
msg: "scope 'foo' not allowed".to_owned(),
@@ -851,6 +851,30 @@ impl TemplateContext for PolicyViolationContext {
variant: None,
}],
);
+ let authorization_grant_admin_scope =
+ PolicyViolationContext::for_authorization_grant(
+ grant.clone(),
+ client.clone(),
+ vec![Violation {
+ msg: "scope 'urn:mas:admin' requires admin privileges".to_owned(),
+ redirect_uri: None,
+ field: None,
+ variant: Some(ViolationVariant::AdminScopeNotAllowed),
+ }],
+ );
+
+ let authorization_grant_client_not_allowed =
+ PolicyViolationContext::for_authorization_grant(
+ grant,
+ client.clone(),
+ vec![Violation {
+ msg: "This client is not allowed to login".to_owned(),
+ redirect_uri: None,
+ field: None,
+ variant: Some(ViolationVariant::ClientNotAllowed),
+ }],
+ );
+
let device_code_grant = PolicyViolationContext::for_device_code_grant(
DeviceCodeGrant {
id: Ulid::from_datetime_with_rng(now, rng),
@@ -898,6 +922,8 @@ impl TemplateContext for PolicyViolationContext {
[
authorization_grant,
authorization_grant_invalid_scope,
+ authorization_grant_admin_scope,
+ authorization_grant_client_not_allowed,
device_code_grant,
device_code_grant_invalid_scope,
]
@@ -976,6 +1002,30 @@ impl TemplateContext for CompatLoginPolicyViolationContext {
variant: Some(ViolationVariant::TooManySessions { need_to_remove: 1 }),
}],
},
+ CompatLoginPolicyViolationContext {
+ violations: vec![Violation {
+ msg: "This client is not allowed to login".to_owned(),
+ redirect_uri: None,
+ field: None,
+ variant: Some(ViolationVariant::ClientNotAllowed),
+ }],
+ },
+ CompatLoginPolicyViolationContext {
+ violations: vec![
+ Violation {
+ msg: "scope 'foo' not allowed".to_owned(),
+ redirect_uri: None,
+ field: None,
+ variant: None,
+ },
+ Violation {
+ msg: "user has too many active sessions".to_owned(),
+ redirect_uri: None,
+ field: None,
+ 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 f02fdaac4..a4d1e0708 100644
--- a/policies/authorization_grant/authorization_grant.rego
+++ b/policies/authorization_grant/authorization_grant.rego
@@ -1,3 +1,4 @@
+# Copyright 2025, 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -88,6 +89,10 @@ allowed_scope("urn:matrix:org.matrix.msc2967.client:api:*") if {
interactive_grant_type(input.grant_type)
}
+admin_scope("urn:synapse:admin:*") := true
+
+admin_scope("urn:mas:admin") := true
+
uses_unstable_scopes if {
scope_list := split(input.scope, " ")
count({scope | some scope in scope_list; startswith(scope, "urn:matrix:org.matrix.msc2967.client:")}) > 0
@@ -122,10 +127,18 @@ has_cs_api_scope if {
# entrypoint: true
violation contains {"msg": msg} if {
some scope in split(input.scope, " ")
+ not admin_scope(scope)
not allowed_scope(scope)
msg := sprintf("scope '%s' not allowed", [scope])
}
+violation contains {"code": "admin-scope-not-allowed", "msg": msg} if {
+ some scope in split(input.scope, " ")
+ admin_scope(scope)
+ not allowed_scope(scope)
+ msg := sprintf("scope '%s' requires admin privileges", [scope])
+}
+
violation contains {"msg": "only one device scope is allowed at a time"} if {
scope_list := split(input.scope, " ")
count({scope | some scope in scope_list; startswith(scope, "urn:matrix:org.matrix.msc2967.client:device:")}) > 1
diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego
index 5186e6c3e..093cc9510 100644
--- a/policies/authorization_grant/authorization_grant_test.rego
+++ b/policies/authorization_grant/authorization_grant_test.rego
@@ -1,3 +1,4 @@
+# Copyright 2025, 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -223,6 +224,32 @@ test_mas_scopes if {
with input.scope as "urn:mas:admin"
}
+test_synapse_admin_scope_violation if {
+ violations := authorization_grant.violation with input.user as user
+ with input.client as client
+ with data.admin_users as []
+ with input.grant_type as "authorization_code"
+ with input.scope as "urn:synapse:admin:*"
+
+ violations == {{
+ "code": "admin-scope-not-allowed",
+ "msg": "scope 'urn:synapse:admin:*' requires admin privileges",
+ }}
+}
+
+test_mas_admin_scope_violation if {
+ violations := authorization_grant.violation with input.user as user
+ with input.client as client
+ with data.admin_users as []
+ with input.grant_type as "authorization_code"
+ with input.scope as "urn:mas:admin"
+
+ violations == {{
+ "code": "admin-scope-not-allowed",
+ "msg": "scope 'urn:mas:admin' requires admin privileges",
+ }}
+}
+
# Helper utility to extract the number of sessions that they `need_to_remove`, returns 0
# if the `too-many-sessions` violation is not found
need_to_remove_sessions(violations) := need if {
diff --git a/templates/pages/compat_login_policy_violation.html b/templates/pages/compat_login_policy_violation.html
index d6b7372dc..2796475f5 100644
--- a/templates/pages/compat_login_policy_violation.html
+++ b/templates/pages/compat_login_policy_violation.html
@@ -1,4 +1,5 @@
{#
+Copyright 2025, 2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -7,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
{% extends "base.html" %}
-{% set is_too_many_sessions = violations | length == 1 and violations[0].code | default("") == "too-many-sessions" %}
-{% set num_sessions_need_to_remove = violations[0].need_to_remove if is_too_many_sessions else none %}
+{% set violation = violations | selectattr("code", "defined") | first | default({}) %}
+{% set code = violation.code | default("") %}
{% block content %}
{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=num_sessions_need_to_remove) }} {{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=violation.need_to_remove) }} {{ _("mas.policy_violation.description") }} {{ _("mas.policy_violation.client_not_allowed.description") }} {{ _("mas.policy_violation.description") }}{{ _("mas.policy_violation.too_many_sessions.heading") }}
- {{ _("mas.policy_violation.heading") }}
- {{ _("mas.policy_violation.client_not_allowed.heading") }}
+ {{ _("mas.policy_violation.heading") }}
+
{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=num_sessions_need_to_remove) }}
-{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=violation.need_to_remove) }}
+ {% elif code == "admin-scope-not-allowed" %} +{{ _("mas.policy_violation.admin_scope_not_allowed.description") }}
+ {% elif code == "client-not-allowed" %} +{{ _("mas.policy_violation.client_not_allowed.description") }}
+ {% else %}{{ _("mas.policy_violation.description") }}
-