Skip to content

Commit c928347

Browse files
authored
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.
1 parent b74c29f commit c928347

File tree

9 files changed

+239
-27
lines changed

9 files changed

+239
-27
lines changed

changelog.d/19203.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add experimentatal implememntation of [MSC4380](https://github.com/matrix-org/matrix-spec-proposals/pull/4380) (invite blocking).

synapse/api/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ class AccountDataTypes:
307307
MSC4155_INVITE_PERMISSION_CONFIG: Final = (
308308
"org.matrix.msc4155.invite_permission_config"
309309
)
310+
# MSC4380: Invite blocking
311+
MSC4380_INVITE_PERMISSION_CONFIG: Final = (
312+
"org.matrix.msc4380.invite_permission_config"
313+
)
310314
# Synapse-specific behaviour. See "Client-Server API Extensions" documentation
311315
# in Admin API for more information.
312316
SYNAPSE_ADMIN_CLIENT_CONFIG: Final = "io.element.synapse.admin_client_config"

synapse/api/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ class Codes(str, Enum):
137137
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
138138
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
139139

140-
# Part of MSC4155
140+
# Part of MSC4155/MSC4380
141141
INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
142142

143143
# Part of MSC4190

synapse/config/experimental.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,6 @@ def read_config(
596596
# MSC4306: Thread Subscriptions
597597
# (and MSC4308: Thread Subscriptions extension to Sliding Sync)
598598
self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False)
599+
600+
# MSC4380: Invite blocking
601+
self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)

synapse/rest/client/versions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
182182
"org.matrix.msc4306": self.config.experimental.msc4306_enabled,
183183
# MSC4169: Backwards-compatible redaction sending using `/send`
184184
"com.beeper.msc4169": self.config.experimental.msc4169_enabled,
185+
# MSC4380: Invite blocking
186+
"org.matrix.msc4380": self.config.experimental.msc4380_enabled,
185187
},
186188
},
187189
)

synapse/storage/databases/main/account_data.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@
4040
)
4141
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
4242
from synapse.storage.databases.main.push_rule import PushRulesWorkerStore
43-
from synapse.storage.invite_rule import InviteRulesConfig
43+
from synapse.storage.invite_rule import (
44+
AllowAllInviteRulesConfig,
45+
InviteRulesConfig,
46+
MSC4155InviteRulesConfig,
47+
MSC4380InviteRulesConfig,
48+
)
4449
from synapse.storage.util.id_generators import MultiWriterIdGenerator
4550
from synapse.types import JsonDict, JsonMapping
4651
from synapse.util.caches.descriptors import cached
@@ -104,6 +109,7 @@ def __init__(
104109
)
105110

106111
self._msc4155_enabled = hs.config.experimental.msc4155_enabled
112+
self._msc4380_enabled = hs.config.experimental.msc4380_enabled
107113

108114
def get_max_account_data_stream_id(self) -> int:
109115
"""Get the current max stream ID for account data stream
@@ -562,20 +568,28 @@ async def ignored_users(self, user_id: str) -> frozenset[str]:
562568

563569
async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
564570
"""
565-
Get the invite configuration for the current user.
571+
Get the invite configuration for the given user.
566572
567573
Args:
568-
user_id:
574+
user_id: The user whose invite configuration should be returned.
569575
"""
576+
if self._msc4380_enabled:
577+
data = await self.get_global_account_data_by_type_for_user(
578+
user_id, AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG
579+
)
580+
# If the user has an MSC4380-style config setting, prioritise that
581+
# above an MSC4155 one
582+
if data is not None:
583+
return MSC4380InviteRulesConfig.from_account_data(data)
584+
585+
if self._msc4155_enabled:
586+
data = await self.get_global_account_data_by_type_for_user(
587+
user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
588+
)
589+
if data is not None:
590+
return MSC4155InviteRulesConfig(data)
570591

571-
if not self._msc4155_enabled:
572-
# This equates to allowing all invites, as if the setting was off.
573-
return InviteRulesConfig(None)
574-
575-
data = await self.get_global_account_data_by_type_for_user(
576-
user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
577-
)
578-
return InviteRulesConfig(data)
592+
return AllowAllInviteRulesConfig()
579593

580594
async def get_admin_client_config_for_user(self, user_id: str) -> AdminClientConfig:
581595
"""

synapse/storage/invite_rule.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
2+
from abc import abstractmethod
23
from enum import Enum
34
from typing import Pattern
45

6+
import attr
57
from matrix_common.regex import glob_to_regex
68

79
from synapse.types import JsonMapping, UserID
@@ -18,9 +20,29 @@ class InviteRule(Enum):
1820

1921

2022
class InviteRulesConfig:
21-
"""Class to determine if a given user permits an invite from another user, and the action to take."""
23+
"""An object encapsulating a given user's choices about whether to accept invites."""
2224

23-
def __init__(self, account_data: JsonMapping | None):
25+
@abstractmethod
26+
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
27+
"""Get the invite rule that matches this user. Will return InviteRule.ALLOW if no rules match
28+
29+
Args:
30+
inviter_user_id: The user ID of the inviting user.
31+
"""
32+
33+
34+
@attr.s(slots=True)
35+
class AllowAllInviteRulesConfig(InviteRulesConfig):
36+
"""An `InviteRulesConfig` implementation which will accept all invites."""
37+
38+
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
39+
return InviteRule.ALLOW
40+
41+
42+
class MSC4155InviteRulesConfig(InviteRulesConfig):
43+
"""An object encapsulating [MSC4155](https://github.com/matrix-org/matrix-spec-proposals/pull/4155) invite rules."""
44+
45+
def __init__(self, account_data: JsonMapping):
2446
self.allowed_users: list[Pattern[str]] = []
2547
self.ignored_users: list[Pattern[str]] = []
2648
self.blocked_users: list[Pattern[str]] = []
@@ -110,3 +132,21 @@ def get_invite_rule(self, user_id: str) -> InviteRule:
110132
return rule
111133

112134
return InviteRule.ALLOW
135+
136+
137+
@attr.s(slots=True, auto_attribs=True)
138+
class MSC4380InviteRulesConfig(InviteRulesConfig):
139+
default_invite_rule: InviteRule
140+
"""The invite rule to apply to all invites."""
141+
142+
@classmethod
143+
def from_account_data(cls, data: JsonMapping) -> "MSC4380InviteRulesConfig":
144+
default = data.get("default_action")
145+
146+
default_invite_rule = (
147+
InviteRule.BLOCK if default == "block" else InviteRule.ALLOW
148+
)
149+
return cls(default_invite_rule=default_invite_rule)
150+
151+
def get_invite_rule(self, inviter_user_id: str) -> InviteRule:
152+
return self.default_invite_rule

tests/handlers/test_room_member.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,9 @@ def test_deduplicate_joins(self) -> None:
458458
self.assertEqual(initial_count, new_count)
459459

460460

461-
class TestInviteFiltering(FederatingHomeserverTestCase):
461+
class TestMSC4155InviteFiltering(FederatingHomeserverTestCase):
462+
"""Tests for MSC4155-style invite filtering."""
463+
462464
servlets = [
463465
synapse.rest.admin.register_servlets,
464466
synapse.rest.client.login.register_servlets,
@@ -618,3 +620,145 @@ def test_msc4155_block_invite_remote_server(self) -> None:
618620
).value
619621
self.assertEqual(f.code, 403)
620622
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
623+
624+
625+
class TestMSC4380InviteBlocking(FederatingHomeserverTestCase):
626+
"""Tests for MSC4380-style invite filtering."""
627+
628+
servlets = [
629+
synapse.rest.admin.register_servlets,
630+
synapse.rest.client.login.register_servlets,
631+
synapse.rest.client.room.register_servlets,
632+
]
633+
634+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
635+
self.handler = hs.get_room_member_handler()
636+
self.fed_handler = hs.get_federation_handler()
637+
self.store = hs.get_datastores().main
638+
639+
# Create two users.
640+
self.alice = self.register_user("alice", "pass")
641+
self.alice_token = self.login("alice", "pass")
642+
self.bob = self.register_user("bob", "pass")
643+
self.bob_token = self.login("bob", "pass")
644+
645+
@override_config({"experimental_features": {"msc4380_enabled": True}})
646+
def test_misc4380_block_invite_local(self) -> None:
647+
"""Test that MSC4380 will block a user from being invited to a room"""
648+
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
649+
650+
self.get_success(
651+
self.store.add_account_data_for_user(
652+
self.bob,
653+
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
654+
{
655+
"default_action": "block",
656+
},
657+
)
658+
)
659+
660+
f = self.get_failure(
661+
self.handler.update_membership(
662+
requester=create_requester(self.alice),
663+
target=UserID.from_string(self.bob),
664+
room_id=room_id,
665+
action=Membership.INVITE,
666+
),
667+
SynapseError,
668+
).value
669+
self.assertEqual(f.code, 403)
670+
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
671+
672+
@override_config({"experimental_features": {"msc4380_enabled": True}})
673+
def test_misc4380_non_string_setting(self) -> None:
674+
"""Test that `default_action` being set to something non-stringy is the same as "accept"."""
675+
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
676+
677+
self.get_success(
678+
self.store.add_account_data_for_user(
679+
self.bob,
680+
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
681+
{
682+
"default_action": 1,
683+
},
684+
)
685+
)
686+
687+
self.get_success(
688+
self.handler.update_membership(
689+
requester=create_requester(self.alice),
690+
target=UserID.from_string(self.bob),
691+
room_id=room_id,
692+
action=Membership.INVITE,
693+
)
694+
)
695+
696+
@override_config({"experimental_features": {"msc4380_enabled": False}})
697+
def test_msc4380_disabled_allow_invite_local(self) -> None:
698+
"""Test that, when MSC4380 is not enabled, invites are accepted as normal"""
699+
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
700+
701+
self.get_success(
702+
self.store.add_account_data_for_user(
703+
self.bob,
704+
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
705+
{
706+
"default_action": "block",
707+
},
708+
)
709+
)
710+
711+
self.get_success(
712+
self.handler.update_membership(
713+
requester=create_requester(self.alice),
714+
target=UserID.from_string(self.bob),
715+
room_id=room_id,
716+
action=Membership.INVITE,
717+
),
718+
)
719+
720+
@override_config({"experimental_features": {"msc4380_enabled": True}})
721+
def test_msc4380_block_invite_remote(self) -> None:
722+
"""Test that MSC4380 will block a user from being invited to a room by a remote user."""
723+
# A remote user who sends the invite
724+
remote_server = "otherserver"
725+
remote_user = "@otheruser:" + remote_server
726+
727+
self.get_success(
728+
self.store.add_account_data_for_user(
729+
self.bob,
730+
AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
731+
{"default_action": "block"},
732+
)
733+
)
734+
735+
room_id = self.helper.create_room_as(
736+
room_creator=self.alice, tok=self.alice_token
737+
)
738+
room_version = self.get_success(self.store.get_room_version(room_id))
739+
740+
invite_event = event_from_pdu_json(
741+
{
742+
"type": EventTypes.Member,
743+
"content": {"membership": "invite"},
744+
"room_id": room_id,
745+
"sender": remote_user,
746+
"state_key": self.bob,
747+
"depth": 32,
748+
"prev_events": [],
749+
"auth_events": [],
750+
"origin_server_ts": self.clock.time_msec(),
751+
},
752+
room_version,
753+
)
754+
755+
f = self.get_failure(
756+
self.fed_handler.on_invite_request(
757+
remote_server,
758+
invite_event,
759+
invite_event.room_version,
760+
),
761+
SynapseError,
762+
).value
763+
self.assertEqual(f.code, 403)
764+
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")

0 commit comments

Comments
 (0)