mirror of
https://github.com/element-hq/synapse.git
synced 2025-12-07 01:20:16 +00:00
Compare commits
3 Commits
madlittlem
...
rav/invite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df4502450 | ||
|
|
27fceb03f9 | ||
|
|
114907e20e |
1
changelog.d/19203.feature
Normal file
1
changelog.d/19203.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add experimentatal implememntation of MSC4380 (invite blocking).
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user