Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19203.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimentatal implememntation of MSC4380 (invite blocking).
Comment thread
richvdh marked this conversation as resolved.
Outdated
4 changes: 4 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion synapse/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,3 +593,6 @@ def read_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)
2 changes: 2 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
"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,
},
},
)
Expand Down
36 changes: 25 additions & 11 deletions synapse/storage/databases/main/account_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@
)
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
Expand Down Expand Up @@ -104,6 +109,7 @@ def __init__(
)

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
Expand Down Expand Up @@ -562,20 +568,28 @@ async def ignored_users(self, user_id: str) -> frozenset[str]:

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 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)

if not self._msc4155_enabled:
# This equates to allowing all invites, as if the setting was off.
return InviteRulesConfig(None)

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:
"""
Expand Down
43 changes: 41 additions & 2 deletions synapse/storage/invite_rule.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]] = []
Expand Down Expand Up @@ -110,3 +132,20 @@ def get_invite_rule(self, user_id: str) -> InviteRule:
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
146 changes: 145 additions & 1 deletion tests/handlers/test_room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,9 @@ def test_deduplicate_joins(self) -> None:
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,
Expand Down Expand Up @@ -618,3 +620,145 @@ def test_msc4155_block_invite_remote_server(self) -> None:
).value
self.assertEqual(f.code, 403)
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")


class TestMSC4380InviteFiltering(FederatingHomeserverTestCase):
Comment thread
richvdh marked this conversation as resolved.
Outdated
"""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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like we should just verify this behavior with some Complement tests instead

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't disagree that Complement tests would be nice to have (in addition), but having these tests mean we get a tighter development cycle and it's easier to test different combinations (eg what happens when msc4380 is disabled).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷 We ran into this with the Sliding Sync tests. We naturally fell into writing Synapse tests but they would have much more benefit as Complement tests so other homeserver implementations can verify behavior.

(eg what happens when msc4380 is disabled).

Out-of-repo Complement tests can also be written for this kind of thing. For example, while we don't have this setup for the Synapse project, we do for the Secure Border Gateway, TI-Messenger Proxy, and Synapse Pro for small hosts. Something for the future here ⏩

"""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")
Loading
Loading