Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2eccb4c
Initial implementation of MSC4155
Half-Shot Mar 27, 2025
c7f6f30
Add a versions flag.
Half-Shot Mar 27, 2025
b033b1a
changelog
Half-Shot Mar 27, 2025
9edc201
tweaks
Half-Shot Mar 27, 2025
79ca4f9
tweak comments / block apply order
Half-Shot Mar 31, 2025
eb116af
Buildup tests.
Half-Shot Mar 31, 2025
5a9b62e
Add mixed rules.
Half-Shot Mar 31, 2025
bf1302d
Finish writing tests
Half-Shot Mar 31, 2025
501796c
Merge remote-tracking branch 'origin/develop' into hs/msc4155-invite-…
Half-Shot Mar 31, 2025
729567f
Merge remote-tracking branch 'origin/develop' into hs/msc4155-invite-…
Half-Shot May 22, 2025
055a3d4
Rewrite for new MSC contents
Half-Shot May 29, 2025
0bd92ec
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot May 29, 2025
5a07938
lint
Half-Shot May 29, 2025
58dfba5
Update changelog.d/18288.feature
Half-Shot May 29, 2025
cd5d797
review bits
Half-Shot May 29, 2025
0b87642
Rewrite so that the server notices exception applies inside room_member
Half-Shot May 29, 2025
5d5cbf9
remove cached because it requires true immutability
Half-Shot May 29, 2025
cebeaba
Users can also match globs
Half-Shot May 29, 2025
dcdf812
Add experimental config to complement
Half-Shot May 29, 2025
38ae50d
Revert accidental user directory change
Half-Shot May 29, 2025
e6ad1c3
everything is globs /o\
Half-Shot May 29, 2025
d72a884
cleanup error handling
Half-Shot May 29, 2025
b455dea
lint
Half-Shot May 29, 2025
652341c
Add msc4155 to test list
Half-Shot May 29, 2025
de55ace
Refactor so that rules are processed in the correct order
Half-Shot May 30, 2025
e967227
Cleanup
Half-Shot May 30, 2025
268c53e
Improve ignore behaviour
Half-Shot Jun 3, 2025
5b696be
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 3, 2025
2f33aab
Apply suggestions from code review
Half-Shot Jun 4, 2025
cb5207b
revert user directory faffage
Half-Shot Jun 4, 2025
6e77fac
Review changes
Half-Shot Jun 4, 2025
72452b4
Remove capabilites as it was removed from the MSC.
Half-Shot Jun 4, 2025
56cd377
lint
Half-Shot Jun 4, 2025
de1392b
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 4, 2025
0a6ffc7
Add pydoc
Half-Shot Jun 4, 2025
8063cc6
cleanup
Half-Shot Jun 4, 2025
f810aa9
is state
Half-Shot Jun 4, 2025
9815587
lint
Half-Shot Jun 4, 2025
6d72827
Fix log line
anoadragon453 Jun 4, 2025
1facef6
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 4, 2025
d9defd8
Add INVITE_BLOCKED error.
Half-Shot Jun 5, 2025
ca76d7b
correct error
Half-Shot Jun 5, 2025
78a69ca
correct error code in tests
Half-Shot Jun 5, 2025
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/18288.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for [MSC4155](https://github.com/matrix-org/matrix-spec-proposals/pull/4155) Invite Filtering.
2 changes: 2 additions & 0 deletions docker/complement/conf/workers-shared-extra.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ experimental_features:
msc3983_appservice_otk_claims: true
# Proxy key queries to exclusive ASes
msc3984_appservice_key_query: true
# Invite filtering
msc4155_enabled: true

server_notices:
system_mxid_localpart: _server
Expand Down
1 change: 1 addition & 0 deletions scripts-dev/complement.sh
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ test_packages=(
./tests/msc3902
./tests/msc3967
./tests/msc4140
./tests/msc4155
)

# Enable dirty runs, so tests will reuse the same container where possible.
Expand Down
4 changes: 4 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ class AccountDataTypes:
IGNORED_USER_LIST: Final = "m.ignored_user_list"
TAG: Final = "m.tag"
PUSH_RULES: Final = "m.push_rules"
# MSC4155: Invite filtering
MSC4155_INVITE_PERMISSION_CONFIG: Final = (
"org.matrix.msc4155.invite_permission_config"
)


class HistoryVisibility:
Expand Down
3 changes: 3 additions & 0 deletions synapse/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ class Codes(str, Enum):
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"

# Part of MSC4155
INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
Copy link
Member Author

Choose a reason for hiding this comment

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



class CodeMessageException(RuntimeError):
"""An exception with integer code, a message string attributes and optional headers.
Expand Down
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,6 @@ def read_config(
"msc4263_limit_key_queries_to_users_who_share_rooms",
False,
)

# MSC4155: Invite filtering
self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False)
15 changes: 15 additions & 0 deletions synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
ReplicationStoreRoomOnOutlierMembershipRestServlet,
)
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
from synapse.storage.invite_rule import InviteRule
from synapse.types import JsonDict, StrCollection, get_domain_from_id
from synapse.types.state import StateFilter
from synapse.util.async_helpers import Linearizer
Expand Down Expand Up @@ -1089,6 +1090,20 @@ async def on_invite_request(
if event.state_key == self._server_notices_mxid:
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")

# check the invitee's configuration and apply rules
invite_config = await self.store.get_invite_config_for_user(event.state_key)
rule = invite_config.get_invite_rule(event.sender)
if rule == InviteRule.BLOCK:
logger.info(
f"Automatically rejecting invite from {event.sender} due to the invite filtering rules of {event.state_key}"
)
raise SynapseError(
403,
"You are not permitted to invite this user.",
errcode=Codes.INVITE_BLOCKED,
)
# InviteRule.IGNORE is handled at the sync layer

# We retrieve the room member handler here as to not cause a cyclic dependency
member_handler = self.hs.get_room_member_handler()
# We don't rate limit based on room ID, as that should be done by
Expand Down
16 changes: 16 additions & 0 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.replication.http.push import ReplicationCopyPusherRestServlet
from synapse.storage.databases.main.state_deltas import StateDelta
from synapse.storage.invite_rule import InviteRule
from synapse.types import (
JsonDict,
Requester,
Expand Down Expand Up @@ -915,6 +916,21 @@ async def update_membership_locked(
additional_fields=block_invite_result[1],
)

# check the invitee's configuration and apply rules. Admins on the server can bypass.
if not is_requester_admin:
invite_config = await self.store.get_invite_config_for_user(target_id)
rule = invite_config.get_invite_rule(requester.user.to_string())
if rule == InviteRule.BLOCK:
logger.info(
f"Automatically rejecting invite from {target_id} due to the the invite filtering rules of {requester.user}"
)
raise SynapseError(
403,
"You are not permitted to invite this user.",
errcode=Codes.INVITE_BLOCKED,
)
# InviteRule.IGNORE is handled at the sync layer.

# An empty prev_events list is allowed as long as the auth_event_ids are present
if prev_event_ids is not None:
return await self._local_membership_update(
Expand Down
11 changes: 10 additions & 1 deletion synapse/handlers/sliding_sync/room_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
Sentinel as StateSentinel,
)
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import (
RoomsForUser,
RoomsForUserSlidingSync,
Expand Down Expand Up @@ -278,6 +279,7 @@ async def _compute_interested_rooms_new_tables(

# Remove invites from ignored users
ignored_users = await self.store.ignored_users(user_id)
invite_config = await self.store.get_invite_config_for_user(user_id)
if ignored_users:
# FIXME: It would be nice to avoid this copy but since
# `get_sliding_sync_rooms_for_user_from_membership_snapshots` is cached, it
Expand All @@ -292,7 +294,14 @@ async def _compute_interested_rooms_new_tables(
room_for_user_sliding_sync = room_membership_for_user_map[room_id]
if (
room_for_user_sliding_sync.membership == Membership.INVITE
and room_for_user_sliding_sync.sender in ignored_users
and room_for_user_sliding_sync.sender
and (
room_for_user_sliding_sync.sender in ignored_users
or invite_config.get_invite_rule(
room_for_user_sliding_sync.sender
)
== InviteRule.IGNORE
)
):
room_membership_for_user_map.pop(room_id, None)

Expand Down
11 changes: 10 additions & 1 deletion synapse/handlers/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
from synapse.storage.databases.main.event_push_actions import RoomNotifCounts
from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary
from synapse.storage.databases.main.stream import PaginateFunction
from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import MemberSummary
from synapse.types import (
DeviceListUpdates,
Expand Down Expand Up @@ -2549,6 +2550,7 @@ async def _get_room_changes_for_incremental_sync(
room_entries: List[RoomSyncResultBuilder] = []
invited: List[InvitedSyncResult] = []
knocked: List[KnockedSyncResult] = []
invite_config = await self.store.get_invite_config_for_user(user_id)
for room_id, events in mem_change_events_by_room_id.items():
# The body of this loop will add this room to at least one of the five lists
# above. Things get messy if you've e.g. joined, left, joined then left the
Expand Down Expand Up @@ -2631,7 +2633,11 @@ async def _get_room_changes_for_incremental_sync(
# Only bother if we're still currently invited
should_invite = last_non_join.membership == Membership.INVITE
if should_invite:
if last_non_join.sender not in ignored_users:
if (
last_non_join.sender not in ignored_users
and invite_config.get_invite_rule(last_non_join.sender)
!= InviteRule.IGNORE
):
invite_room_sync = InvitedSyncResult(room_id, invite=last_non_join)
if invite_room_sync:
invited.append(invite_room_sync)
Expand Down Expand Up @@ -2786,6 +2792,7 @@ async def _get_room_changes_for_initial_sync(
membership_list=Membership.LIST,
excluded_rooms=sync_result_builder.excluded_room_ids,
)
invite_config = await self.store.get_invite_config_for_user(user_id)

room_entries = []
invited = []
Expand All @@ -2811,6 +2818,8 @@ async def _get_room_changes_for_initial_sync(
elif event.membership == Membership.INVITE:
if event.sender in ignored_users:
continue
if invite_config.get_invite_rule(event.sender) == InviteRule.IGNORE:
continue
invite = await self.store.get_event(event.event_id)
invited.append(InvitedSyncResult(room_id=event.room_id, invite=invite))
elif event.membership == Membership.KNOCK:
Expand Down
13 changes: 11 additions & 2 deletions synapse/push/bulk_push_rule_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from synapse.logging.context import make_deferred_yieldable, run_in_background
from synapse.state import POWER_KEY
from synapse.storage.databases.main.roommember import EventIdMembership
from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import ProfileInfo
from synapse.synapse_rust.push import FilteredPushRules, PushRuleEvaluator
from synapse.types import JsonValue
Expand Down Expand Up @@ -191,9 +192,17 @@ async def _get_rules_for_event(

# if this event is an invite event, we may need to run rules for the user
# who's been invited, otherwise they won't get told they've been invited
if event.type == EventTypes.Member and event.membership == Membership.INVITE:
if (
event.is_state()
and event.type == EventTypes.Member
and event.membership == Membership.INVITE
):
invited = event.state_key
if invited and self.hs.is_mine_id(invited) and invited not in local_users:
invite_config = await self.store.get_invite_config_for_user(invited)
if invite_config.get_invite_rule(event.sender) != InviteRule.ALLOW:
# Invite was blocked or ignored, never notify.
return {}
if self.hs.is_mine_id(invited) and invited not in local_users:
local_users.append(invited)

if not local_users:
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
"org.matrix.simplified_msc3575": msc3575_enabled,
# Arbitrary key-value profile fields.
"uk.tcpip.msc4133": self.config.experimental.msc4133_enabled,
# MSC4155: Invite filtering
"org.matrix.msc4155": self.config.experimental.msc4155_enabled,
},
},
)
Expand Down
20 changes: 20 additions & 0 deletions synapse/storage/databases/main/account_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
)
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.util.id_generators import MultiWriterIdGenerator
from synapse.types import JsonDict, JsonMapping
from synapse.util import json_encoder
Expand Down Expand Up @@ -102,6 +103,8 @@ def __init__(
self._delete_account_data_for_deactivated_users,
)

self._msc4155_enabled = hs.config.experimental.msc4155_enabled

def get_max_account_data_stream_id(self) -> int:
"""Get the current max stream ID for account data stream

Expand Down Expand Up @@ -557,6 +560,23 @@ 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.

Args:
user_id:
"""

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)

def process_replication_rows(
self,
stream_name: str,
Expand Down
1 change: 1 addition & 0 deletions synapse/storage/databases/main/user_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

USE_ICU = True
except ModuleNotFoundError:
# except ModuleNotFoundError:
USE_ICU = False

from synapse.api.errors import StoreError
Expand Down
110 changes: 110 additions & 0 deletions synapse/storage/invite_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import logging
from enum import Enum
from typing import Optional, Pattern

from matrix_common.regex import glob_to_regex

from synapse.types import JsonMapping, UserID

logger = logging.getLogger(__name__)


class InviteRule(Enum):
"""Enum to define the action taken when an invite matches a rule."""

ALLOW = "allow"
BLOCK = "block"
IGNORE = "ignore"


class InviteRulesConfig:
"""Class to determine if a given user permits an invite from another user, and the action to take."""

def __init__(self, account_data: Optional[JsonMapping]):
self.allowed_users: list[Pattern[str]] = []
self.ignored_users: list[Pattern[str]] = []
self.blocked_users: list[Pattern[str]] = []

self.allowed_servers: list[Pattern[str]] = []
self.ignored_servers: list[Pattern[str]] = []
self.blocked_servers: list[Pattern[str]] = []

def process_field(
values: Optional[list[str]],
ruleset: list[Pattern[str]],
rule: InviteRule,
) -> None:
if isinstance(values, list):
for value in values:
if isinstance(value, str) and len(value) > 0:
# User IDs cannot exceed 255 bytes. Don't process large, potentially
# expensive glob patterns.
if len(value) > 255:
logger.debug(
"Ignoring invite config glob pattern that is >255 bytes: {value}"
)
continue

try:
ruleset.append(glob_to_regex(value))
except Exception as e:
# If for whatever reason we can't process this, just ignore it.
logger.debug(
f"Could not process '{value}' field of invite rule config, ignoring: {e}"
)

if account_data:
process_field(
account_data.get("allowed_users"), self.allowed_users, InviteRule.ALLOW
)
process_field(
account_data.get("ignored_users"), self.ignored_users, InviteRule.IGNORE
)
process_field(
account_data.get("blocked_users"), self.blocked_users, InviteRule.BLOCK
)
process_field(
account_data.get("allowed_servers"),
self.allowed_servers,
InviteRule.ALLOW,
)
process_field(
account_data.get("ignored_servers"),
self.ignored_servers,
InviteRule.IGNORE,
)
process_field(
account_data.get("blocked_servers"),
self.blocked_servers,
InviteRule.BLOCK,
)

def get_invite_rule(self, user_id: str) -> InviteRule:
"""Get the invite rule that matches this user. Will return InviteRule.ALLOW if no rules match

Args:
user_id: The user ID of the inviting user.

"""
user = UserID.from_string(user_id)
# The order here is important. We always process user rules before server rules
# and we always process in the order of Allow, Ignore, Block.
for patterns, rule in [
(self.allowed_users, InviteRule.ALLOW),
(self.ignored_users, InviteRule.IGNORE),
(self.blocked_users, InviteRule.BLOCK),
]:
for regex in patterns:
if regex.match(user_id):
return rule

for patterns, rule in [
(self.allowed_servers, InviteRule.ALLOW),
(self.ignored_servers, InviteRule.IGNORE),
(self.blocked_servers, InviteRule.BLOCK),
]:
for regex in patterns:
if regex.match(user.domain):
return rule

return InviteRule.ALLOW
Loading
Loading