Implement MSC4380: Invite blocking (#19203)

MSC4380 aims to be a simplified implementation of MSC4155; the hope is
that we can get it specced and rolled out rapidly, so that we can
resolve the fact that `matrix.org` has enabled MSC4155.

The implementation leans heavily on what's already there for MSC4155.

It has its own `experimental_features` flag. If both MSC4155 and MSC4380
are enabled, and a user has both configurations set, then we prioritise
the MSC4380 one.

Contributed wearing my 🎩 Spec Core Team hat.
This commit is contained in:
Richard van der Hoff
2025-11-26 16:12:14 +00:00
committed by GitHub
parent b74c29f694
commit c928347779
9 changed files with 238 additions and 26 deletions

View File

@@ -0,0 +1 @@
Add experimentatal implememntation of [MSC4380](https://github.com/matrix-org/matrix-spec-proposals/pull/4380) (invite blocking).

View File

@@ -307,6 +307,10 @@ class AccountDataTypes:
MSC4155_INVITE_PERMISSION_CONFIG: Final = ( MSC4155_INVITE_PERMISSION_CONFIG: Final = (
"org.matrix.msc4155.invite_permission_config" "org.matrix.msc4155.invite_permission_config"
) )
# MSC4380: Invite blocking
MSC4380_INVITE_PERMISSION_CONFIG: Final = (
"org.matrix.msc4380.invite_permission_config"
)
# Synapse-specific behaviour. See "Client-Server API Extensions" documentation # Synapse-specific behaviour. See "Client-Server API Extensions" documentation
# in Admin API for more information. # in Admin API for more information.
SYNAPSE_ADMIN_CLIENT_CONFIG: Final = "io.element.synapse.admin_client_config" SYNAPSE_ADMIN_CLIENT_CONFIG: Final = "io.element.synapse.admin_client_config"

View File

@@ -137,7 +137,7 @@ class Codes(str, Enum):
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE" PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
KEY_TOO_LARGE = "M_KEY_TOO_LARGE" KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
# Part of MSC4155 # Part of MSC4155/MSC4380
INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED" INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
# Part of MSC4190 # Part of MSC4190

View File

@@ -596,3 +596,6 @@ class ExperimentalConfig(Config):
# MSC4306: Thread Subscriptions # MSC4306: Thread Subscriptions
# (and MSC4308: Thread Subscriptions extension to Sliding Sync) # (and MSC4308: Thread Subscriptions extension to Sliding Sync)
self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False) self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False)
# MSC4380: Invite blocking
self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)

View File

@@ -182,6 +182,8 @@ class VersionsRestServlet(RestServlet):
"org.matrix.msc4306": self.config.experimental.msc4306_enabled, "org.matrix.msc4306": self.config.experimental.msc4306_enabled,
# MSC4169: Backwards-compatible redaction sending using `/send` # MSC4169: Backwards-compatible redaction sending using `/send`
"com.beeper.msc4169": self.config.experimental.msc4169_enabled, "com.beeper.msc4169": self.config.experimental.msc4169_enabled,
# MSC4380: Invite blocking
"org.matrix.msc4380": self.config.experimental.msc4380_enabled,
}, },
}, },
) )

View File

@@ -40,7 +40,12 @@ from synapse.storage.database import (
) )
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
from synapse.storage.databases.main.push_rule import PushRulesWorkerStore from synapse.storage.databases.main.push_rule import PushRulesWorkerStore
from synapse.storage.invite_rule import InviteRulesConfig from synapse.storage.invite_rule import (
AllowAllInviteRulesConfig,
InviteRulesConfig,
MSC4155InviteRulesConfig,
MSC4380InviteRulesConfig,
)
from synapse.storage.util.id_generators import MultiWriterIdGenerator from synapse.storage.util.id_generators import MultiWriterIdGenerator
from synapse.types import JsonDict, JsonMapping from synapse.types import JsonDict, JsonMapping
from synapse.util.caches.descriptors import cached from synapse.util.caches.descriptors import cached
@@ -104,6 +109,7 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
) )
self._msc4155_enabled = hs.config.experimental.msc4155_enabled self._msc4155_enabled = hs.config.experimental.msc4155_enabled
self._msc4380_enabled = hs.config.experimental.msc4380_enabled
def get_max_account_data_stream_id(self) -> int: def get_max_account_data_stream_id(self) -> int:
"""Get the current max stream ID for account data stream """Get the current max stream ID for account data stream
@@ -562,20 +568,28 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig: async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
""" """
Get the invite configuration for the current user. Get the invite configuration for the given user.
Args: Args:
user_id: user_id: The user whose invite configuration should be returned.
""" """
if self._msc4380_enabled:
data = await self.get_global_account_data_by_type_for_user(
user_id, AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG
)
# If the user has an MSC4380-style config setting, prioritise that
# above an MSC4155 one
if data is not None:
return MSC4380InviteRulesConfig.from_account_data(data)
if not self._msc4155_enabled: if self._msc4155_enabled:
# This equates to allowing all invites, as if the setting was off. data = await self.get_global_account_data_by_type_for_user(
return InviteRulesConfig(None) user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
)
if data is not None:
return MSC4155InviteRulesConfig(data)
data = await self.get_global_account_data_by_type_for_user( return AllowAllInviteRulesConfig()
user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
)
return InviteRulesConfig(data)
async def get_admin_client_config_for_user(self, user_id: str) -> AdminClientConfig: async def get_admin_client_config_for_user(self, user_id: str) -> AdminClientConfig:
""" """

View File

@@ -1,7 +1,9 @@
import logging import logging
from abc import abstractmethod
from enum import Enum from enum import Enum
from typing import Pattern from typing import Pattern
import attr
from matrix_common.regex import glob_to_regex from matrix_common.regex import glob_to_regex
from synapse.types import JsonMapping, UserID from synapse.types import JsonMapping, UserID
@@ -18,9 +20,29 @@ class InviteRule(Enum):
class InviteRulesConfig: class InviteRulesConfig:
"""Class to determine if a given user permits an invite from another user, and the action to take.""" """An object encapsulating a given user's choices about whether to accept invites."""
def __init__(self, account_data: JsonMapping | None): @abstractmethod
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
"""Get the invite rule that matches this user. Will return InviteRule.ALLOW if no rules match
Args:
inviter_user_id: The user ID of the inviting user.
"""
@attr.s(slots=True)
class AllowAllInviteRulesConfig(InviteRulesConfig):
"""An `InviteRulesConfig` implementation which will accept all invites."""
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
return InviteRule.ALLOW
class MSC4155InviteRulesConfig(InviteRulesConfig):
"""An object encapsulating [MSC4155](https://github.com/matrix-org/matrix-spec-proposals/pull/4155) invite rules."""
def __init__(self, account_data: JsonMapping):
self.allowed_users: list[Pattern[str]] = [] self.allowed_users: list[Pattern[str]] = []
self.ignored_users: list[Pattern[str]] = [] self.ignored_users: list[Pattern[str]] = []
self.blocked_users: list[Pattern[str]] = [] self.blocked_users: list[Pattern[str]] = []
@@ -110,3 +132,21 @@ class InviteRulesConfig:
return rule return rule
return InviteRule.ALLOW return InviteRule.ALLOW
@attr.s(slots=True, auto_attribs=True)
class MSC4380InviteRulesConfig(InviteRulesConfig):
default_invite_rule: InviteRule
"""The invite rule to apply to all invites."""
@classmethod
def from_account_data(cls, data: JsonMapping) -> "MSC4380InviteRulesConfig":
default = data.get("default_action")
default_invite_rule = (
InviteRule.BLOCK if default == "block" else InviteRule.ALLOW
)
return cls(default_invite_rule=default_invite_rule)
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
return self.default_invite_rule

View File

@@ -458,7 +458,9 @@ class RoomMemberMasterHandlerTestCase(HomeserverTestCase):
self.assertEqual(initial_count, new_count) self.assertEqual(initial_count, new_count)
class TestInviteFiltering(FederatingHomeserverTestCase): class TestMSC4155InviteFiltering(FederatingHomeserverTestCase):
"""Tests for MSC4155-style invite filtering."""
servlets = [ servlets = [
synapse.rest.admin.register_servlets, synapse.rest.admin.register_servlets,
synapse.rest.client.login.register_servlets, synapse.rest.client.login.register_servlets,
@@ -618,3 +620,145 @@ class TestInviteFiltering(FederatingHomeserverTestCase):
).value ).value
self.assertEqual(f.code, 403) self.assertEqual(f.code, 403)
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED") self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
class TestMSC4380InviteBlocking(FederatingHomeserverTestCase):
"""Tests for MSC4380-style invite filtering."""
servlets = [
synapse.rest.admin.register_servlets,
synapse.rest.client.login.register_servlets,
synapse.rest.client.room.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.handler = hs.get_room_member_handler()
self.fed_handler = hs.get_federation_handler()
self.store = hs.get_datastores().main
# Create two users.
self.alice = self.register_user("alice", "pass")
self.alice_token = self.login("alice", "pass")
self.bob = self.register_user("bob", "pass")
self.bob_token = self.login("bob", "pass")
@override_config({"experimental_features": {"msc4380_enabled": True}})
def test_misc4380_block_invite_local(self) -> None:
"""Test that MSC4380 will block a user from being invited to a room"""
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
self.get_success(
self.store.add_account_data_for_user(
self.bob,
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
{
"default_action": "block",
},
)
)
f = self.get_failure(
self.handler.update_membership(
requester=create_requester(self.alice),
target=UserID.from_string(self.bob),
room_id=room_id,
action=Membership.INVITE,
),
SynapseError,
).value
self.assertEqual(f.code, 403)
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
@override_config({"experimental_features": {"msc4380_enabled": True}})
def test_misc4380_non_string_setting(self) -> None:
"""Test that `default_action` being set to something non-stringy is the same as "accept"."""
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
self.get_success(
self.store.add_account_data_for_user(
self.bob,
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
{
"default_action": 1,
},
)
)
self.get_success(
self.handler.update_membership(
requester=create_requester(self.alice),
target=UserID.from_string(self.bob),
room_id=room_id,
action=Membership.INVITE,
)
)
@override_config({"experimental_features": {"msc4380_enabled": False}})
def test_msc4380_disabled_allow_invite_local(self) -> None:
"""Test that, when MSC4380 is not enabled, invites are accepted as normal"""
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
self.get_success(
self.store.add_account_data_for_user(
self.bob,
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
{
"default_action": "block",
},
)
)
self.get_success(
self.handler.update_membership(
requester=create_requester(self.alice),
target=UserID.from_string(self.bob),
room_id=room_id,
action=Membership.INVITE,
),
)
@override_config({"experimental_features": {"msc4380_enabled": True}})
def test_msc4380_block_invite_remote(self) -> None:
"""Test that MSC4380 will block a user from being invited to a room by a remote user."""
# A remote user who sends the invite
remote_server = "otherserver"
remote_user = "@otheruser:" + remote_server
self.get_success(
self.store.add_account_data_for_user(
self.bob,
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
{"default_action": "block"},
)
)
room_id = self.helper.create_room_as(
room_creator=self.alice, tok=self.alice_token
)
room_version = self.get_success(self.store.get_room_version(room_id))
invite_event = event_from_pdu_json(
{
"type": EventTypes.Member,
"content": {"membership": "invite"},
"room_id": room_id,
"sender": remote_user,
"state_key": self.bob,
"depth": 32,
"prev_events": [],
"auth_events": [],
"origin_server_ts": self.clock.time_msec(),
},
room_version,
)
f = self.get_failure(
self.fed_handler.on_invite_request(
remote_server,
invite_event,
invite_event.room_version,
),
SynapseError,
).value
self.assertEqual(f.code, 403)
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")

View File

@@ -1,4 +1,8 @@
from synapse.storage.invite_rule import InviteRule, InviteRulesConfig from synapse.storage.invite_rule import (
AllowAllInviteRulesConfig,
InviteRule,
MSC4155InviteRulesConfig,
)
from synapse.types import UserID from synapse.types import UserID
from tests import unittest from tests import unittest
@@ -10,23 +14,23 @@ ignored_user = UserID.from_string("@ignored:ignore.example.org")
class InviteFilterTestCase(unittest.TestCase): class InviteFilterTestCase(unittest.TestCase):
def test_empty(self) -> None: def test_allow_all(self) -> None:
"""Permit by default""" """Permit by default"""
config = InviteRulesConfig(None) config = AllowAllInviteRulesConfig()
self.assertEqual( self.assertEqual(
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
) )
def test_ignore_invalid(self) -> None: def test_ignore_invalid(self) -> None:
"""Invalid strings are ignored""" """Invalid strings are ignored"""
config = InviteRulesConfig({"blocked_users": ["not a user"]}) config = MSC4155InviteRulesConfig({"blocked_users": ["not a user"]})
self.assertEqual( self.assertEqual(
config.get_invite_rule(blocked_user.to_string()), InviteRule.ALLOW config.get_invite_rule(blocked_user.to_string()), InviteRule.ALLOW
) )
def test_user_blocked(self) -> None: def test_user_blocked(self) -> None:
"""Permit all, except explicitly blocked users""" """Permit all, except explicitly blocked users"""
config = InviteRulesConfig({"blocked_users": [blocked_user.to_string()]}) config = MSC4155InviteRulesConfig({"blocked_users": [blocked_user.to_string()]})
self.assertEqual( self.assertEqual(
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
) )
@@ -36,7 +40,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_user_ignored(self) -> None: def test_user_ignored(self) -> None:
"""Permit all, except explicitly ignored users""" """Permit all, except explicitly ignored users"""
config = InviteRulesConfig({"ignored_users": [ignored_user.to_string()]}) config = MSC4155InviteRulesConfig({"ignored_users": [ignored_user.to_string()]})
self.assertEqual( self.assertEqual(
config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE
) )
@@ -46,7 +50,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_user_precedence(self) -> None: def test_user_precedence(self) -> None:
"""Always take allowed over ignored, ignored over blocked, and then block.""" """Always take allowed over ignored, ignored over blocked, and then block."""
config = InviteRulesConfig( config = MSC4155InviteRulesConfig(
{ {
"allowed_users": [allowed_user.to_string()], "allowed_users": [allowed_user.to_string()],
"ignored_users": [allowed_user.to_string(), ignored_user.to_string()], "ignored_users": [allowed_user.to_string(), ignored_user.to_string()],
@@ -70,7 +74,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_blocked(self) -> None: def test_server_blocked(self) -> None:
"""Block all users on the server except those allowed.""" """Block all users on the server except those allowed."""
user_on_same_server = UserID("blocked", allowed_user.domain) user_on_same_server = UserID("blocked", allowed_user.domain)
config = InviteRulesConfig( config = MSC4155InviteRulesConfig(
{ {
"allowed_users": [allowed_user.to_string()], "allowed_users": [allowed_user.to_string()],
"blocked_servers": [allowed_user.domain], "blocked_servers": [allowed_user.domain],
@@ -86,7 +90,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_ignored(self) -> None: def test_server_ignored(self) -> None:
"""Ignore all users on the server except those allowed.""" """Ignore all users on the server except those allowed."""
user_on_same_server = UserID("ignored", allowed_user.domain) user_on_same_server = UserID("ignored", allowed_user.domain)
config = InviteRulesConfig( config = MSC4155InviteRulesConfig(
{ {
"allowed_users": [allowed_user.to_string()], "allowed_users": [allowed_user.to_string()],
"ignored_servers": [allowed_user.domain], "ignored_servers": [allowed_user.domain],
@@ -104,7 +108,7 @@ class InviteFilterTestCase(unittest.TestCase):
blocked_user_on_same_server = UserID("blocked", allowed_user.domain) blocked_user_on_same_server = UserID("blocked", allowed_user.domain)
ignored_user_on_same_server = UserID("ignored", allowed_user.domain) ignored_user_on_same_server = UserID("ignored", allowed_user.domain)
allowed_user_on_same_server = UserID("another", allowed_user.domain) allowed_user_on_same_server = UserID("another", allowed_user.domain)
config = InviteRulesConfig( config = MSC4155InviteRulesConfig(
{ {
"ignored_users": [ignored_user_on_same_server.to_string()], "ignored_users": [ignored_user_on_same_server.to_string()],
"blocked_users": [blocked_user_on_same_server.to_string()], "blocked_users": [blocked_user_on_same_server.to_string()],
@@ -129,7 +133,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_precedence(self) -> None: def test_server_precedence(self) -> None:
"""Always take allowed over ignored, ignored over blocked, and then block.""" """Always take allowed over ignored, ignored over blocked, and then block."""
config = InviteRulesConfig( config = MSC4155InviteRulesConfig(
{ {
"allowed_servers": [allowed_user.domain], "allowed_servers": [allowed_user.domain],
"ignored_servers": [allowed_user.domain, ignored_user.domain], "ignored_servers": [allowed_user.domain, ignored_user.domain],
@@ -152,7 +156,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_glob(self) -> None: def test_server_glob(self) -> None:
"""Test that glob patterns match""" """Test that glob patterns match"""
config = InviteRulesConfig({"blocked_servers": ["*.example.org"]}) config = MSC4155InviteRulesConfig({"blocked_servers": ["*.example.org"]})
self.assertEqual( self.assertEqual(
config.get_invite_rule(allowed_user.to_string()), InviteRule.BLOCK config.get_invite_rule(allowed_user.to_string()), InviteRule.BLOCK
) )