Compare commits

...

3 Commits

Author SHA1 Message Date
Richard van der Hoff
9df4502450 changelog 2025-11-19 00:00:07 +00:00
Richard van der Hoff
27fceb03f9 MSC4380 implementation
An implementation of MSC4380, leaning 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.
2025-11-19 00:00:07 +00:00
Richard van der Hoff
114907e20e Lay some groundwork for MSC4380 implementation
In particular, make `InviteRulesConfig` a base class which can have multiple
operations. To demonstarate it, use an `AllowAllInviteRulesConfig` class for
the common case where the user has no config.
2025-11-19 00:00:07 +00:00
9 changed files with 237 additions and 26 deletions

View File

@@ -0,0 +1 @@
Add experimentatal implememntation of MSC4380 (invite blocking).

View File

@@ -307,6 +307,10 @@ class AccountDataTypes:
MSC4155_INVITE_PERMISSION_CONFIG: Final = (
"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
# in Admin API for more information.
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"
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
# Part of MSC4155
# Part of MSC4155/MSC4380
INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
# Part of MSC4190

View File

@@ -593,3 +593,6 @@ class ExperimentalConfig(Config):
# MSC4306: Thread Subscriptions
# (and MSC4308: Thread Subscriptions extension to Sliding Sync)
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,
# MSC4169: Backwards-compatible redaction sending using `/send`
"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.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.types import JsonDict, JsonMapping
from synapse.util.caches.descriptors import cached
@@ -104,6 +109,7 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
)
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:
"""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:
"""
Get the invite configuration for the current user.
Get the invite configuration for the given user.
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:
# This equates to allowing all invites, as if the setting was off.
return InviteRulesConfig(None)
if self._msc4155_enabled:
data = await self.get_global_account_data_by_type_for_user(
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(
user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
)
return InviteRulesConfig(data)
return AllowAllInviteRulesConfig()
async def get_admin_client_config_for_user(self, user_id: str) -> AdminClientConfig:
"""

View File

@@ -1,7 +1,9 @@
import logging
from abc import abstractmethod
from enum import Enum
from typing import Pattern
import attr
from matrix_common.regex import glob_to_regex
from synapse.types import JsonMapping, UserID
@@ -18,9 +20,29 @@ class InviteRule(Enum):
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.ignored_users: list[Pattern[str]] = []
self.blocked_users: list[Pattern[str]] = []
@@ -110,3 +132,20 @@ class InviteRulesConfig:
return rule
return InviteRule.ALLOW
@attr.s(slots=True, auto_attribs=True)
class MSC4380InviteRulesConfig(InviteRulesConfig):
block_all: bool
"""If true, all invites are blocked."""
@classmethod
def from_account_data(cls, data: JsonMapping) -> "MSC4380InviteRulesConfig":
block_all = data.get("block_all")
if not isinstance(block_all, bool):
block_all = False
return cls(block_all=block_all)
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
return InviteRule.BLOCK if self.block_all else InviteRule.ALLOW

View File

@@ -458,7 +458,9 @@ class RoomMemberMasterHandlerTestCase(HomeserverTestCase):
self.assertEqual(initial_count, new_count)
class TestInviteFiltering(FederatingHomeserverTestCase):
class TestMSC4155InviteFiltering(FederatingHomeserverTestCase):
"""Tests for MSC4155-style invite filtering."""
servlets = [
synapse.rest.admin.register_servlets,
synapse.rest.client.login.register_servlets,
@@ -618,3 +620,145 @@ class TestInviteFiltering(FederatingHomeserverTestCase):
).value
self.assertEqual(f.code, 403)
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
class TestMSC4380InviteFiltering(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,
{
"block_all": True,
},
)
)
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_bool_setting(self) -> None:
"""Test that `block_all` being set to something non-booly is the same as False."""
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,
{
"block_all": "True",
},
)
)
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 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,
{
"block_all": True,
},
)
)
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,
{"block_all": True},
)
)
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 tests import unittest
@@ -10,23 +14,23 @@ ignored_user = UserID.from_string("@ignored:ignore.example.org")
class InviteFilterTestCase(unittest.TestCase):
def test_empty(self) -> None:
def test_allow_all(self) -> None:
"""Permit by default"""
config = InviteRulesConfig(None)
config = AllowAllInviteRulesConfig()
self.assertEqual(
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
)
def test_ignore_invalid(self) -> None:
"""Invalid strings are ignored"""
config = InviteRulesConfig({"blocked_users": ["not a user"]})
config = MSC4155InviteRulesConfig({"blocked_users": ["not a user"]})
self.assertEqual(
config.get_invite_rule(blocked_user.to_string()), InviteRule.ALLOW
)
def test_user_blocked(self) -> None:
"""Permit all, except explicitly blocked users"""
config = InviteRulesConfig({"blocked_users": [blocked_user.to_string()]})
config = MSC4155InviteRulesConfig({"blocked_users": [blocked_user.to_string()]})
self.assertEqual(
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
)
@@ -36,7 +40,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_user_ignored(self) -> None:
"""Permit all, except explicitly ignored users"""
config = InviteRulesConfig({"ignored_users": [ignored_user.to_string()]})
config = MSC4155InviteRulesConfig({"ignored_users": [ignored_user.to_string()]})
self.assertEqual(
config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE
)
@@ -46,7 +50,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_user_precedence(self) -> None:
"""Always take allowed over ignored, ignored over blocked, and then block."""
config = InviteRulesConfig(
config = MSC4155InviteRulesConfig(
{
"allowed_users": [allowed_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:
"""Block all users on the server except those allowed."""
user_on_same_server = UserID("blocked", allowed_user.domain)
config = InviteRulesConfig(
config = MSC4155InviteRulesConfig(
{
"allowed_users": [allowed_user.to_string()],
"blocked_servers": [allowed_user.domain],
@@ -86,7 +90,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_ignored(self) -> None:
"""Ignore all users on the server except those allowed."""
user_on_same_server = UserID("ignored", allowed_user.domain)
config = InviteRulesConfig(
config = MSC4155InviteRulesConfig(
{
"allowed_users": [allowed_user.to_string()],
"ignored_servers": [allowed_user.domain],
@@ -104,7 +108,7 @@ class InviteFilterTestCase(unittest.TestCase):
blocked_user_on_same_server = UserID("blocked", allowed_user.domain)
ignored_user_on_same_server = UserID("ignored", 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()],
"blocked_users": [blocked_user_on_same_server.to_string()],
@@ -129,7 +133,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_precedence(self) -> None:
"""Always take allowed over ignored, ignored over blocked, and then block."""
config = InviteRulesConfig(
config = MSC4155InviteRulesConfig(
{
"allowed_servers": [allowed_user.domain],
"ignored_servers": [allowed_user.domain, ignored_user.domain],
@@ -152,7 +156,7 @@ class InviteFilterTestCase(unittest.TestCase):
def test_server_glob(self) -> None:
"""Test that glob patterns match"""
config = InviteRulesConfig({"blocked_servers": ["*.example.org"]})
config = MSC4155InviteRulesConfig({"blocked_servers": ["*.example.org"]})
self.assertEqual(
config.get_invite_rule(allowed_user.to_string()), InviteRule.BLOCK
)