diff --git a/changelog.d/11668.feature b/changelog.d/11668.feature new file mode 100644 index 000000000000..a80a158be6b8 --- /dev/null +++ b/changelog.d/11668.feature @@ -0,0 +1 @@ +Add support for [MSC3613](https://github.com/matrix-org/matrix-doc/pull/3613): Combined join rules. \ No newline at end of file diff --git a/synapse/api/room_versions.py b/synapse/api/room_versions.py index a747a4081497..723f24265a6c 100644 --- a/synapse/api/room_versions.py +++ b/synapse/api/room_versions.py @@ -81,6 +81,8 @@ class RoomVersion: msc2716_historical: bool # MSC2716: Adds support for redacting "insertion", "chunk", and "marker" events msc2716_redactions: bool + # MSC3613: Allows for concurrent join rules in a simplified manner + msc3613_simplified_join_rules: bool class RoomVersions: @@ -99,6 +101,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V2 = RoomVersion( "2", @@ -115,6 +118,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V3 = RoomVersion( "3", @@ -131,6 +135,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V4 = RoomVersion( "4", @@ -147,6 +152,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V5 = RoomVersion( "5", @@ -163,6 +169,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V6 = RoomVersion( "6", @@ -179,6 +186,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) MSC2176 = RoomVersion( "org.matrix.msc2176", @@ -195,6 +203,7 @@ class RoomVersions: msc2403_knocking=False, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V7 = RoomVersion( "7", @@ -211,6 +220,7 @@ class RoomVersions: msc2403_knocking=True, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V8 = RoomVersion( "8", @@ -227,6 +237,7 @@ class RoomVersions: msc2403_knocking=True, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) V9 = RoomVersion( "9", @@ -243,6 +254,7 @@ class RoomVersions: msc2403_knocking=True, msc2716_historical=False, msc2716_redactions=False, + msc3613_simplified_join_rules=False, ) MSC2716v3 = RoomVersion( "org.matrix.msc2716v3", @@ -259,6 +271,25 @@ class RoomVersions: msc2403_knocking=True, msc2716_historical=True, msc2716_redactions=True, + msc3613_simplified_join_rules=False, + ) + MSC3613 = RoomVersion( + # v9 + MSC3613 + "org.matrix.msc3613", + RoomDisposition.UNSTABLE, + EventFormatVersions.V3, + StateResolutionVersions.V2, + enforce_key_validity=True, + special_case_aliases_auth=False, + strict_canonicaljson=True, + limit_notifications_power_levels=True, + msc2176_redaction_rules=False, + msc3083_join_rules=True, + msc3375_redaction_rules=True, + msc2403_knocking=True, + msc2716_historical=False, + msc2716_redactions=False, + msc3613_simplified_join_rules=True, ) @@ -276,6 +307,7 @@ class RoomVersions: RoomVersions.V8, RoomVersions.V9, RoomVersions.MSC2716v3, + RoomVersions.MSC3613, ) } diff --git a/synapse/event_auth.py b/synapse/event_auth.py index eca00bc97506..c2e7255e3c90 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -36,6 +36,7 @@ RoomVersion, ) from synapse.types import StateMap, UserID, get_domain_from_id +from synapse.util.join_rules import is_join_rule as is_join_rule_in_version if typing.TYPE_CHECKING: # conditional imports to avoid import cycle @@ -339,11 +340,10 @@ def _is_membership_change_allowed( target_banned = target and target.membership == Membership.BAN key = (EventTypes.JoinRules, "") - join_rule_event = auth_events.get(key) - if join_rule_event: - join_rule = join_rule_event.content.get("join_rule", JoinRules.INVITE) - else: - join_rule = JoinRules.INVITE + join_rule_event: Optional[EventBase] = auth_events.get(key) + + def is_join_rule(rule: JoinRules) -> bool: + return is_join_rule_in_version(room_version, join_rule_event, rule) user_level = get_user_power_level(event.user_id, auth_events) target_level = get_user_power_level(target_user_id, auth_events) @@ -360,7 +360,7 @@ def _is_membership_change_allowed( "target_banned": target_banned, "target_in_room": target_in_room, "membership": membership, - "join_rule": join_rule, + "join_rules": join_rule_event.content if join_rule_event else "unset", "target_user_id": target_user_id, "event.user_id": event.user_id, }, @@ -412,9 +412,9 @@ def _is_membership_change_allowed( raise AuthError(403, "Cannot force another user to join.") elif target_banned: raise AuthError(403, "You are banned from this room") - elif join_rule == JoinRules.PUBLIC: + elif is_join_rule(JoinRules.PUBLIC): pass - elif room_version.msc3083_join_rules and join_rule == JoinRules.RESTRICTED: + elif room_version.msc3083_join_rules and is_join_rule(JoinRules.RESTRICTED): # This is the same as public, but the event must contain a reference # to the server who authorised the join. If the event does not contain # the proper content it is rejected. @@ -440,8 +440,8 @@ def _is_membership_change_allowed( if authorising_user_level < invite_level: raise AuthError(403, "Join event authorised by invalid server.") - elif join_rule == JoinRules.INVITE or ( - room_version.msc2403_knocking and join_rule == JoinRules.KNOCK + elif is_join_rule(JoinRules.INVITE) or ( + room_version.msc2403_knocking and is_join_rule(JoinRules.KNOCK) ): if not caller_in_room and not caller_invited: raise AuthError(403, "You are not invited to this room.") @@ -462,7 +462,7 @@ def _is_membership_change_allowed( if user_level < ban_level or user_level <= target_level: raise AuthError(403, "You don't have permission to ban") elif room_version.msc2403_knocking and Membership.KNOCK == membership: - if join_rule != JoinRules.KNOCK: + if not is_join_rule(JoinRules.KNOCK): raise AuthError(403, "You don't have permission to knock") elif target_user_id != event.user_id: raise AuthError(403, "You cannot knock for other users") diff --git a/synapse/events/utils.py b/synapse/events/utils.py index 9386fa29ddd3..5d0b8ff0970c 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -161,6 +161,11 @@ def add_fields(*fields: str) -> None: add_fields(EventContentFields.MSC2716_BATCH_ID) elif room_version.msc2716_redactions and event_type == EventTypes.MSC2716_MARKER: add_fields(EventContentFields.MSC2716_MARKER_INSERTION) + elif ( + room_version.msc3613_simplified_join_rules + and event_type == EventTypes.JoinRules + ): + add_fields("join_rules") allowed_fields = {k: v for k, v in event_dict.items() if k in allowed_keys} diff --git a/synapse/handlers/event_auth.py b/synapse/handlers/event_auth.py index 365063ebdf77..2edb44f5189b 100644 --- a/synapse/handlers/event_auth.py +++ b/synapse/handlers/event_auth.py @@ -28,6 +28,7 @@ from synapse.events.builder import EventBuilder from synapse.events.snapshot import EventContext from synapse.types import StateMap, get_domain_from_id +from synapse.util.join_rules import get_all_allow_lists, is_join_rule from synapse.util.metrics import Measure if TYPE_CHECKING: @@ -198,7 +199,7 @@ async def check_restricted_join_rules( # Get the rooms which allow access to this room and check if the user is # in any of them. - allowed_rooms = await self.get_rooms_that_allow_join(state_ids) + allowed_rooms = await self.get_rooms_that_allow_join(state_ids, room_version) if not await self.is_user_in_rooms(allowed_rooms, user_id): # If this is a remote request, the user might be in an allowed room @@ -241,16 +242,19 @@ async def has_restricted_join_rules( # If the join rule is not restricted, this doesn't apply. join_rules_event = await self._store.get_event(join_rules_event_id) - return join_rules_event.content.get("join_rule") == JoinRules.RESTRICTED + return is_join_rule(room_version, join_rules_event, JoinRules.RESTRICTED) async def get_rooms_that_allow_join( - self, state_ids: StateMap[str] + self, + state_ids: StateMap[str], + room_version: RoomVersion, ) -> Collection[str]: """ Generate a list of rooms in which membership allows access to a room. Args: state_ids: The current state of the room the user wishes to join + room_version: The version of the room Returns: A collection of room IDs. Membership in any of the rooms in the list grants the ability to join the target room. @@ -264,8 +268,8 @@ async def get_rooms_that_allow_join( join_rules_event = await self._store.get_event(join_rules_event_id) # If allowed is of the wrong form, then only allow invited users. - allow_list = join_rules_event.content.get("allow", []) - if not isinstance(allow_list, list): + allow_list = get_all_allow_lists(room_version, join_rules_event) + if allow_list is None: return () # Pull out the other room IDs, invalid data gets filtered. diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index a719d5eef351..bfe4d7ffbd93 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -41,6 +41,7 @@ from synapse.spam_checker_api import RegistrationBehaviour from synapse.storage.state import StateFilter from synapse.types import RoomAlias, UserID, create_requester +from synapse.util.join_rules import is_join_rule if TYPE_CHECKING: from synapse.server import HomeServer @@ -538,9 +539,11 @@ async def _join_rooms(self, user_id: str) -> None: event_id, allow_none=True ) if join_rules_event: - join_rule = join_rules_event.content.get("join_rule", None) - requires_invite = ( - join_rule and join_rule != JoinRules.PUBLIC + room_version = await self.store.get_room_version(r) + requires_invite = not is_join_rule( + room_version, + join_rules_event, + JoinRules.PUBLIC, ) # Send the invite, if necessary. diff --git a/synapse/handlers/room_list.py b/synapse/handlers/room_list.py index 1a33211a1fe1..73fb7a994b7f 100644 --- a/synapse/handlers/room_list.py +++ b/synapse/handlers/room_list.py @@ -35,6 +35,7 @@ from synapse.types import JsonDict, ThirdPartyInstanceID from synapse.util.caches.descriptors import _CacheContext, cached from synapse.util.caches.response_cache import ResponseCache +from synapse.util.join_rules import is_join_rule if TYPE_CHECKING: from synapse.server import HomeServer @@ -306,8 +307,13 @@ async def generate_room_entry( join_rules_event = current_state.get((EventTypes.JoinRules, "")) if join_rules_event: - join_rule = join_rules_event.content.get("join_rule", None) - if not allow_private and join_rule and join_rule != JoinRules.PUBLIC: + room_version = await self.store.get_room_version(room_id) + is_public = is_join_rule( + room_version, + join_rules_event, + JoinRules.PUBLIC, + ) + if not allow_private and not is_public: return None # Return whether this room is open to federation users or not diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index b2adc0f48be9..a677b4165c73 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -1420,6 +1420,9 @@ async def _make_and_store_3pid_invite( if room_create_event: room_type = room_create_event.content.get(EventContentFields.ROOM_TYPE) + # Note: for email invites we use the backwards compatible `join_rule` + # as it is meant to represent the "most semantically relevant" join + # rule for the room. See MSC2613 for details. room_join_rules = "" join_rules_event = room_state.get((EventTypes.JoinRules, "")) if join_rules_event: diff --git a/synapse/handlers/room_summary.py b/synapse/handlers/room_summary.py index 4844b69a0345..3a403c4d9904 100644 --- a/synapse/handlers/room_summary.py +++ b/synapse/handlers/room_summary.py @@ -40,6 +40,7 @@ from synapse.events import EventBase from synapse.types import JsonDict, Requester from synapse.util.caches.response_cache import ResponseCache +from synapse.util.join_rules import is_join_rule if TYPE_CHECKING: from synapse.server import HomeServer @@ -858,10 +859,9 @@ async def _is_local_room_accessible( join_rules_event_id = state_ids.get((EventTypes.JoinRules, "")) if join_rules_event_id: join_rules_event = await self._store.get_event(join_rules_event_id) - join_rule = join_rules_event.content.get("join_rule") - if join_rule == JoinRules.PUBLIC or ( - room_version.msc2403_knocking and join_rule == JoinRules.KNOCK - ): + is_public = is_join_rule(room_version, join_rules_event, JoinRules.PUBLIC) + is_knock = is_join_rule(room_version, join_rules_event, JoinRules.KNOCK) + if is_public or (room_version.msc2403_knocking and is_knock): return True # Include the room if it is peekable. @@ -890,7 +890,9 @@ async def _is_local_room_accessible( state_ids, room_version ): allowed_rooms = ( - await self._event_auth_handler.get_rooms_that_allow_join(state_ids) + await self._event_auth_handler.get_rooms_that_allow_join( + state_ids, room_version + ) ) if await self._event_auth_handler.is_user_in_rooms( allowed_rooms, requester @@ -912,7 +914,9 @@ async def _is_local_room_accessible( state_ids, room_version ): allowed_rooms = ( - await self._event_auth_handler.get_rooms_that_allow_join(state_ids) + await self._event_auth_handler.get_rooms_that_allow_join( + state_ids, room_version + ) ) for space_id in allowed_rooms: if await self._event_auth_handler.check_host_in_room( @@ -952,7 +956,12 @@ async def _is_remote_room_accessible( """ # The API doesn't return the room version so assume that a # join rule of knock is valid. + # + # Note: We're relying on the backwards compatible `join_rule` + # for the room as it is meant to reference the "most semantically + # relevant" join rule. See MSC3613 for details. if ( + # TODO: Use is_join_rule utility room.get("join_rules") in (JoinRules.PUBLIC, JoinRules.KNOCK) or room.get("world_readable") is True ): @@ -1023,7 +1032,7 @@ async def _build_room_entry(self, room_id: str, for_federation: bool) -> JsonDic ): allowed_rooms = ( await self._event_auth_handler.get_rooms_that_allow_join( - current_state_ids + current_state_ids, room_version ) ) if allowed_rooms: diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index 5b706efbcff0..2c36d577c8f7 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -36,6 +36,7 @@ from synapse.storage.databases.main.room import RoomSortOrder from synapse.types import JsonDict, RoomID, UserID, create_requester from synapse.util import json_decoder +from synapse.util.join_rules import is_join_rule if TYPE_CHECKING: from synapse.api.auth import Auth @@ -439,6 +440,7 @@ def __init__(self, hs: "HomeServer"): self.auth = hs.get_auth() self.admin_handler = hs.get_admin_handler() self.state_handler = hs.get_state_handler() + self.store = hs.get_datastore() self.is_mine = hs.is_mine async def on_POST( @@ -479,22 +481,22 @@ async def on_POST( target_user, authenticated_entity=requester.authenticated_entity ) - # send invite if room has "JoinRules.INVITE" + # send invite if room has invite-requiring join rules room_state = await self.state_handler.get_current_state(room_id) + room_version = await self.store.get_room_version(room_id) join_rules_event = room_state.get((EventTypes.JoinRules, "")) - if join_rules_event: - if not (join_rules_event.content.get("join_rule") == JoinRules.PUBLIC): - # update_membership with an action of "invite" can raise a - # ShadowBanError. This is not handled since it is assumed that - # an admin isn't going to call this API with a shadow-banned user. - await self.room_member_handler.update_membership( - requester=requester, - target=fake_requester.user, - room_id=room_id, - action="invite", - remote_room_hosts=remote_room_hosts, - ratelimit=False, - ) + if not is_join_rule(room_version, join_rules_event, JoinRules.PUBLIC): + # update_membership with an action of "invite" can raise a + # ShadowBanError. This is not handled since it is assumed that + # an admin isn't going to call this API with a shadow-banned user. + await self.room_member_handler.update_membership( + requester=requester, + target=fake_requester.user, + room_id=room_id, + action="invite", + remote_room_hosts=remote_room_hosts, + ratelimit=False, + ) await self.room_member_handler.update_membership( requester=fake_requester, @@ -636,9 +638,8 @@ async def on_POST( return HTTPStatus.OK, {} join_rules = room_state.get((EventTypes.JoinRules, "")) - is_public = False - if join_rules: - is_public = join_rules.content.get("join_rule") == JoinRules.PUBLIC + room_version = await self.store.get_room_version(room_id) + is_public = is_join_rule(room_version, join_rules, JoinRules.PUBLIC) if is_public: return HTTPStatus.OK, {} diff --git a/synapse/storage/databases/main/stats.py b/synapse/storage/databases/main/stats.py index 427ae1f649b1..dc0d61081752 100644 --- a/synapse/storage/databases/main/stats.py +++ b/synapse/storage/databases/main/stats.py @@ -589,6 +589,9 @@ def _fetch_current_state_stats( for event in state_event_map.values(): if event.type == EventTypes.JoinRules: + # Note: we use the backwards compatible `join_rule` key for join + # rules as it's meant to represent the "most semantically relevant" + # join rule for the room. See MSC3613 for details. room_state["join_rules"] = event.content.get("join_rule") elif event.type == EventTypes.RoomHistoryVisibility: room_state["history_visibility"] = event.content.get( diff --git a/synapse/storage/databases/main/user_directory.py b/synapse/storage/databases/main/user_directory.py index f7c778bdf22b..ea9b7b20e4ad 100644 --- a/synapse/storage/databases/main/user_directory.py +++ b/synapse/storage/databases/main/user_directory.py @@ -27,6 +27,7 @@ ) from synapse.api.errors import StoreError +from synapse.util.join_rules import is_join_rule if TYPE_CHECKING: from synapse.server import HomeServer @@ -440,9 +441,9 @@ async def is_room_world_readable_or_publicly_joinable(self, room_id: str) -> boo join_rules_id = current_state_ids.get((EventTypes.JoinRules, "")) if join_rules_id: join_rule_ev = await self.get_event(join_rules_id, allow_none=True) - if join_rule_ev: - if join_rule_ev.content.get("join_rule") == JoinRules.PUBLIC: - return True + room_version = await self.hs.get_datastore().get_room_version(room_id) + if is_join_rule(room_version, join_rule_ev, JoinRules.PUBLIC): + return True hist_vis_id = current_state_ids.get((EventTypes.RoomHistoryVisibility, "")) if hist_vis_id: diff --git a/synapse/util/join_rules.py b/synapse/util/join_rules.py new file mode 100644 index 000000000000..ac7d757b32a2 --- /dev/null +++ b/synapse/util/join_rules.py @@ -0,0 +1,80 @@ +# Copyright 2021-2022 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Optional + +from synapse.api.constants import JoinRules +from synapse.api.room_versions import RoomVersion +from synapse.events import EventBase + + +def is_join_rule( + room_version: RoomVersion, event: Optional[EventBase], expected_rule: JoinRules +) -> bool: + """Returns whether the join rule event matches the expected join rule. + + Args: + room_version: The RoomVersion the event is meant to be in + event: The join rules event, if known + expected_rule: The anticipated rule + + Returns: + bool: True if the join rule is as expected. + """ + if not event: + return expected_rule == JoinRules.INVITE + + if room_version.msc3613_simplified_join_rules: + arr = event.content.get("join_rules", []) + if arr and isinstance(arr, list): + return expected_rule in (r.get("join_rule", None) for r in arr) + + return event.content.get("join_rule", None) == expected_rule + + +def get_all_allow_lists( + room_version: RoomVersion, event: Optional[EventBase] +) -> Optional[list]: + """Returns the combination of all 'allow' lists in the join rules. + + If the allow list is wholly invalid, None is returned instead. + + Args: + room_version: The RoomVersion the event is meant to be in + event: The join rules event (if known) + + Returns: + Optional[list]: The allow lists from the event, merged + """ + allow_list = [] + is_using_msc3613 = False + if room_version.msc3613_simplified_join_rules: + is_using_msc3613 = True + rules = event.content.get("join_rules", []) + if rules and isinstance(rules, list): + for rule in rules: + if rule.get("join_rule", None) == JoinRules.RESTRICTED: + secondary = rule.get("allow", []) + # Ignore invalid values, but process valid ones. + if secondary and isinstance(secondary, list): + allow_list.extend(secondary) + + # Only look at the top level `allow` list if the event doesn't specify + # multiple join rules. + is_restricted = event.get("join_rule", None) == JoinRules.RESTRICTED + if not is_using_msc3613 and is_restricted: + allow_list = event.content.get("allow", []) + if not allow_list or not isinstance(allow_list, list): + return None # invalid + + return allow_list[:] # clone to prevent mutation