From 17bc6167d694426469eaecd207c57a3339a7e566 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 11 Nov 2021 20:21:50 +0000 Subject: [PATCH 01/15] Annotate string constants in `synapse.api.constants` with `Final` This change makes mypy complain if the constants are ever reassigned, and, more usefully, makes mypy type them as `Literal`s instead of `str`s, allowing code of the following form to pass mypy: ```py def do_something(membership: Literal["join", "leave"], ...): ... do_something(Membership.JOIN, ...) ``` --- changelog.d/11356.misc | 1 + synapse/api/constants.py | 198 ++++++++++++++++++++------------------- 2 files changed, 101 insertions(+), 98 deletions(-) create mode 100644 changelog.d/11356.misc diff --git a/changelog.d/11356.misc b/changelog.d/11356.misc new file mode 100644 index 000000000000..01ce6a306cf4 --- /dev/null +++ b/changelog.d/11356.misc @@ -0,0 +1 @@ +Add `Final` annotation to string constants in `synapse.api.constants` so that they get typed as `Literal`s. diff --git a/synapse/api/constants.py b/synapse/api/constants.py index a33ac341614a..f7d29b431936 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -17,6 +17,8 @@ """Contains constants from the specification.""" +from typing_extensions import Final + # the max size of a (canonical-json-encoded) event MAX_PDU_SIZE = 65536 @@ -39,125 +41,125 @@ class Membership: """Represents the membership states of a user in a room.""" - INVITE = "invite" - JOIN = "join" - KNOCK = "knock" - LEAVE = "leave" - BAN = "ban" - LIST = (INVITE, JOIN, KNOCK, LEAVE, BAN) + INVITE: Final = "invite" + JOIN: Final = "join" + KNOCK: Final = "knock" + LEAVE: Final = "leave" + BAN: Final = "ban" + LIST: Final = (INVITE, JOIN, KNOCK, LEAVE, BAN) class PresenceState: """Represents the presence state of a user.""" - OFFLINE = "offline" - UNAVAILABLE = "unavailable" - ONLINE = "online" - BUSY = "org.matrix.msc3026.busy" + OFFLINE: Final = "offline" + UNAVAILABLE: Final = "unavailable" + ONLINE: Final = "online" + BUSY: Final = "org.matrix.msc3026.busy" class JoinRules: - PUBLIC = "public" - KNOCK = "knock" - INVITE = "invite" - PRIVATE = "private" + PUBLIC: Final = "public" + KNOCK: Final = "knock" + INVITE: Final = "invite" + PRIVATE: Final = "private" # As defined for MSC3083. - RESTRICTED = "restricted" + RESTRICTED: Final = "restricted" class RestrictedJoinRuleTypes: """Understood types for the allow rules in restricted join rules.""" - ROOM_MEMBERSHIP = "m.room_membership" + ROOM_MEMBERSHIP: Final = "m.room_membership" class LoginType: - PASSWORD = "m.login.password" - EMAIL_IDENTITY = "m.login.email.identity" - MSISDN = "m.login.msisdn" - RECAPTCHA = "m.login.recaptcha" - TERMS = "m.login.terms" - SSO = "m.login.sso" - DUMMY = "m.login.dummy" - REGISTRATION_TOKEN = "org.matrix.msc3231.login.registration_token" + PASSWORD: Final = "m.login.password" + EMAIL_IDENTITY: Final = "m.login.email.identity" + MSISDN: Final = "m.login.msisdn" + RECAPTCHA: Final = "m.login.recaptcha" + TERMS: Final = "m.login.terms" + SSO: Final = "m.login.sso" + DUMMY: Final = "m.login.dummy" + REGISTRATION_TOKEN: Final = "org.matrix.msc3231.login.registration_token" # This is used in the `type` parameter for /register when called by # an appservice to register a new user. -APP_SERVICE_REGISTRATION_TYPE = "m.login.application_service" +APP_SERVICE_REGISTRATION_TYPE: Final = "m.login.application_service" class EventTypes: - Member = "m.room.member" - Create = "m.room.create" - Tombstone = "m.room.tombstone" - JoinRules = "m.room.join_rules" - PowerLevels = "m.room.power_levels" - Aliases = "m.room.aliases" - Redaction = "m.room.redaction" - ThirdPartyInvite = "m.room.third_party_invite" - RelatedGroups = "m.room.related_groups" - - RoomHistoryVisibility = "m.room.history_visibility" - CanonicalAlias = "m.room.canonical_alias" - Encrypted = "m.room.encrypted" - RoomAvatar = "m.room.avatar" - RoomEncryption = "m.room.encryption" - GuestAccess = "m.room.guest_access" + Member: Final = "m.room.member" + Create: Final = "m.room.create" + Tombstone: Final = "m.room.tombstone" + JoinRules: Final = "m.room.join_rules" + PowerLevels: Final = "m.room.power_levels" + Aliases: Final = "m.room.aliases" + Redaction: Final = "m.room.redaction" + ThirdPartyInvite: Final = "m.room.third_party_invite" + RelatedGroups: Final = "m.room.related_groups" + + RoomHistoryVisibility: Final = "m.room.history_visibility" + CanonicalAlias: Final = "m.room.canonical_alias" + Encrypted: Final = "m.room.encrypted" + RoomAvatar: Final = "m.room.avatar" + RoomEncryption: Final = "m.room.encryption" + GuestAccess: Final = "m.room.guest_access" # These are used for validation - Message = "m.room.message" - Topic = "m.room.topic" - Name = "m.room.name" + Message: Final = "m.room.message" + Topic: Final = "m.room.topic" + Name: Final = "m.room.name" - ServerACL = "m.room.server_acl" - Pinned = "m.room.pinned_events" + ServerACL: Final = "m.room.server_acl" + Pinned: Final = "m.room.pinned_events" - Retention = "m.room.retention" + Retention: Final = "m.room.retention" - Dummy = "org.matrix.dummy_event" + Dummy: Final = "org.matrix.dummy_event" - SpaceChild = "m.space.child" - SpaceParent = "m.space.parent" + SpaceChild: Final = "m.space.child" + SpaceParent: Final = "m.space.parent" - MSC2716_INSERTION = "org.matrix.msc2716.insertion" - MSC2716_BATCH = "org.matrix.msc2716.batch" - MSC2716_MARKER = "org.matrix.msc2716.marker" + MSC2716_INSERTION: Final = "org.matrix.msc2716.insertion" + MSC2716_BATCH: Final = "org.matrix.msc2716.batch" + MSC2716_MARKER: Final = "org.matrix.msc2716.marker" class ToDeviceEventTypes: - RoomKeyRequest = "m.room_key_request" + RoomKeyRequest: Final = "m.room_key_request" class DeviceKeyAlgorithms: """Spec'd algorithms for the generation of per-device keys""" - ED25519 = "ed25519" - CURVE25519 = "curve25519" - SIGNED_CURVE25519 = "signed_curve25519" + ED25519: Final = "ed25519" + CURVE25519: Final = "curve25519" + SIGNED_CURVE25519: Final = "signed_curve25519" class EduTypes: - Presence = "m.presence" + Presence: Final = "m.presence" class RejectedReason: - AUTH_ERROR = "auth_error" + AUTH_ERROR: Final = "auth_error" class RoomCreationPreset: - PRIVATE_CHAT = "private_chat" - PUBLIC_CHAT = "public_chat" - TRUSTED_PRIVATE_CHAT = "trusted_private_chat" + PRIVATE_CHAT: Final = "private_chat" + PUBLIC_CHAT: Final = "public_chat" + TRUSTED_PRIVATE_CHAT: Final = "trusted_private_chat" class ThirdPartyEntityKind: - USER = "user" - LOCATION = "location" + USER: Final = "user" + LOCATION: Final = "location" -ServerNoticeMsgType = "m.server_notice" -ServerNoticeLimitReached = "m.server_notice.usage_limit_reached" +ServerNoticeMsgType: Final = "m.server_notice" +ServerNoticeLimitReached: Final = "m.server_notice.usage_limit_reached" class UserTypes: @@ -165,91 +167,91 @@ class UserTypes: 'admin' and 'guest' users should also be UserTypes. Normal users are type None """ - SUPPORT = "support" - BOT = "bot" - ALL_USER_TYPES = (SUPPORT, BOT) + SUPPORT: Final = "support" + BOT: Final = "bot" + ALL_USER_TYPES: Final = (SUPPORT, BOT) class RelationTypes: """The types of relations known to this server.""" - ANNOTATION = "m.annotation" - REPLACE = "m.replace" - REFERENCE = "m.reference" - THREAD = "io.element.thread" + ANNOTATION: Final = "m.annotation" + REPLACE: Final = "m.replace" + REFERENCE: Final = "m.reference" + THREAD: Final = "io.element.thread" class LimitBlockingTypes: """Reasons that a server may be blocked""" - MONTHLY_ACTIVE_USER = "monthly_active_user" - HS_DISABLED = "hs_disabled" + MONTHLY_ACTIVE_USER: Final = "monthly_active_user" + HS_DISABLED: Final = "hs_disabled" class EventContentFields: """Fields found in events' content, regardless of type.""" # Labels for the event, cf https://github.com/matrix-org/matrix-doc/pull/2326 - LABELS = "org.matrix.labels" + LABELS: Final = "org.matrix.labels" # Timestamp to delete the event after # cf https://github.com/matrix-org/matrix-doc/pull/2228 - SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after" + SELF_DESTRUCT_AFTER: Final = "org.matrix.self_destruct_after" # cf https://github.com/matrix-org/matrix-doc/pull/1772 - ROOM_TYPE = "type" + ROOM_TYPE: Final = "type" # Whether a room can federate. - FEDERATE = "m.federate" + FEDERATE: Final = "m.federate" # The creator of the room, as used in `m.room.create` events. - ROOM_CREATOR = "creator" + ROOM_CREATOR: Final = "creator" # Used in m.room.guest_access events. - GUEST_ACCESS = "guest_access" + GUEST_ACCESS: Final = "guest_access" # Used on normal messages to indicate they were historically imported after the fact - MSC2716_HISTORICAL = "org.matrix.msc2716.historical" + MSC2716_HISTORICAL: Final = "org.matrix.msc2716.historical" # For "insertion" events to indicate what the next batch ID should be in # order to connect to it - MSC2716_NEXT_BATCH_ID = "org.matrix.msc2716.next_batch_id" + MSC2716_NEXT_BATCH_ID: Final = "org.matrix.msc2716.next_batch_id" # Used on "batch" events to indicate which insertion event it connects to - MSC2716_BATCH_ID = "org.matrix.msc2716.batch_id" + MSC2716_BATCH_ID: Final = "org.matrix.msc2716.batch_id" # For "marker" events - MSC2716_MARKER_INSERTION = "org.matrix.msc2716.marker.insertion" + MSC2716_MARKER_INSERTION: Final = "org.matrix.msc2716.marker.insertion" # The authorising user for joining a restricted room. - AUTHORISING_USER = "join_authorised_via_users_server" + AUTHORISING_USER: Final = "join_authorised_via_users_server" class RoomTypes: """Understood values of the room_type field of m.room.create events.""" - SPACE = "m.space" + SPACE: Final = "m.space" class RoomEncryptionAlgorithms: - MEGOLM_V1_AES_SHA2 = "m.megolm.v1.aes-sha2" - DEFAULT = MEGOLM_V1_AES_SHA2 + MEGOLM_V1_AES_SHA2: Final = "m.megolm.v1.aes-sha2" + DEFAULT: Final = MEGOLM_V1_AES_SHA2 class AccountDataTypes: - DIRECT = "m.direct" - IGNORED_USER_LIST = "m.ignored_user_list" + DIRECT: Final = "m.direct" + IGNORED_USER_LIST: Final = "m.ignored_user_list" class HistoryVisibility: - INVITED = "invited" - JOINED = "joined" - SHARED = "shared" - WORLD_READABLE = "world_readable" + INVITED: Final = "invited" + JOINED: Final = "joined" + SHARED: Final = "shared" + WORLD_READABLE: Final = "world_readable" class GuestAccess: - CAN_JOIN = "can_join" + CAN_JOIN: Final = "can_join" # anything that is not "can_join" is considered "forbidden", but for completeness: - FORBIDDEN = "forbidden" + FORBIDDEN: Final = "forbidden" class ReadReceiptEventFields: - MSC2285_HIDDEN = "org.matrix.msc2285.hidden" + MSC2285_HIDDEN: Final = "org.matrix.msc2285.hidden" From 98873d7be301d7488c4706142513181aa309f496 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Tue, 16 Nov 2021 13:28:47 +0000 Subject: [PATCH 02/15] Add `RoomHierarchyHandler` for walking space hierarchies --- synapse/handlers/room_hierarchy.py | 274 ++++++++++++++++++++++++++++ synapse/handlers/room_summary.py | 6 +- synapse/server.py | 5 + tests/handlers/test_room_summary.py | 4 +- 4 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 synapse/handlers/room_hierarchy.py diff --git a/synapse/handlers/room_hierarchy.py b/synapse/handlers/room_hierarchy.py new file mode 100644 index 000000000000..54cdc130bdbc --- /dev/null +++ b/synapse/handlers/room_hierarchy.py @@ -0,0 +1,274 @@ +# Copyright 2021 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. + +import logging +from typing import ( + TYPE_CHECKING, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, +) + +from synapse.api.constants import EventContentFields, EventTypes, RoomTypes +from synapse.api.errors import SynapseError +from synapse.handlers.room_summary import child_events_comparison_key, has_valid_via +from synapse.storage.state import StateFilter +from synapse.types import JsonDict + +if TYPE_CHECKING: + from synapse.server import HomeServer + + +logger = logging.getLogger(__name__) + + +class RoomHierarchyHandler: + """Provides methods for walking over space hierarchies. + + Also see `RoomSummaryHandler`, which has similar functionality. + """ + + def __init__(self, hs: "HomeServer"): + self._store = hs.get_datastore() + self._federation_client = hs.get_federation_client() + + self._server_name = hs.hostname + + async def get_room_descendants( + self, space_id: str, via: Optional[Iterable[str]] = None + ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]: + """Gets the children of a space, recursively. + + Args: + space_id: The room ID of the space. + + Returns: + A tuple containing: + * A list of (room ID, via) tuples, representing the descendants of the + space. `space_id` is included in the list. + * A list of room IDs whose children could not be fully listed. + Rooms in this list are either spaces not known locally, and thus require + listing over federation, or are unknown rooms or subspaces completely + inaccessible to the local homeserver which may contain further rooms. + + This list is a subset of the previous list, except it may include + `space_id`. + """ + via = via or [] + + # (room ID, via, federation room chunks) + todo: List[Tuple[str, Iterable[str], Mapping[str, Optional[JsonDict]]]] = [ + (space_id, via, {}) + ] + descendants: List[Tuple[str, Iterable[str]]] = [] + + seen = {space_id} + + inaccessible_room_ids: List[str] = [] + + while todo: + space_id, via, federation_room_chunks = todo.pop() + descendants.append((space_id, via)) + try: + ( + is_in_room, + children, + federation_room_chunks, + ) = await self._get_room_children(space_id, via, federation_room_chunks) + except SynapseError: + # Could not list children over federation + inaccessible_room_ids.append(space_id) + continue + + for child_room_id, child_via in reversed(children): + if child_room_id in seen: + continue + + seen.add(child_room_id) + + # Queue up the child for processing. + # The child may not actually be a space, but that's checked by + # `_get_room_children`. + todo.append((child_room_id, child_via, federation_room_chunks)) + + # Children were retrieved over federation, which is not guaranteed to be + # the full list. + if not is_in_room: + inaccessible_room_ids.append(space_id) + + return descendants, inaccessible_room_ids + + async def _get_room_children( + self, + space_id: str, + via: Optional[Iterable[str]] = None, + federation_room_chunks: Optional[Mapping[str, Optional[JsonDict]]] = None, + ) -> Tuple[ + bool, Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] + ]: + """Gets the direct children of a space. + + Args: + space_id: The room ID of the space. + via: A list of servers which may know about the space. + federation_room_chunks: A cache of room chunks previously returned by + `_get_room_children` that may be used to skip federation requests for + inaccessible or non-space rooms. + + Returns: + A tuple containing: + * A boolean indicating whether `space_id` is known to the local homeserver. + * A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + * A dictionary of child room ID: `PublicRoomsChunk`s returned over + federation: + https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms + These are supposed to include extra `room_type` and `allowed_room_ids` + fields, as described in MSC2946. + + Contains `None` for rooms to which the remote homeserver thinks we do not + have access. + + Local information about rooms should be trusted over data in this + dictionary. + + Raises: + SynapseError: if `space_id` is not known locally and its children could not + be retrieved over federation. + """ + via = via or [] + federation_room_chunks = federation_room_chunks or {} + + is_in_room = await self._store.is_host_joined(space_id, self._server_name) + if is_in_room: + children = await self._get_room_children_local(space_id) + return True, children, {} + else: + # Check the room chunks previously returned over federation to see if we + # should really make a request. + # `federation_room_chunks` is intentionally not used earlier since we want + # to trust local data over data from federation. + if space_id in federation_room_chunks: + room_chunk = federation_room_chunks[space_id] + if room_chunk is None: + # `space_id` is inaccessible to the local homeserver according to + # federation. + raise SynapseError( + 502, f"{space_id} is not accessible to the local homeserver" + ) + elif room_chunk.get("room_type") != RoomTypes.SPACE: + # `space_id` is not a space according to federation. + return False, [], {} + + children, room_chunks = await self._get_room_children_remote(space_id, via) + return False, children, room_chunks + + async def _get_room_children_local( + self, space_id: str + ) -> Sequence[Tuple[str, Iterable[str]]]: + """Gets the direct children of a space that the local homeserver is in. + + Args: + space_id: The room ID of the space. + + Returns: + A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + + Raises: + ValueError: if `space_id` is not known locally. + """ + # Fetch the `m.room.create` and `m.space.child` events for `space_id` + state_filter = StateFilter.from_types( + [(EventTypes.Create, ""), (EventTypes.SpaceChild, None)] + ) + current_state_ids = await self._store.get_filtered_current_state_ids( + space_id, state_filter + ) + state_events = await self._store.get_events_as_list(current_state_ids.values()) + assert len(state_events) == len(current_state_ids) + + create_event_id = current_state_ids.get((EventTypes.Create, "")) + if create_event_id is None: + # The local homeserver is not in this room + raise ValueError(f"{space_id} is not a room known locally.") + + create_event = next( + event for event in state_events if event.event_id == create_event_id + ) + if create_event.content.get(EventContentFields.ROOM_TYPE) != RoomTypes.SPACE: + # `space_id` is a regular room and not a space. + # Ignore any `m.space.child` events. + return [] + + child_events = [ + event + for event in state_events + # Ignore events with a missing or non-array `via`, as per MSC1772 + if event.event_id != create_event_id and has_valid_via(event) + ] + child_events.sort(key=child_events_comparison_key) + return [(event.state_key, event.content["via"]) for event in child_events] + + async def _get_room_children_remote( + self, space_id: str, via: Iterable[str] + ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]]: + """Gets the direct children of a space over federation. + + Args: + space_id: The room ID of the space. + via: A list of servers which may know about the space. + + Returns: + A tuple containing: + * A list of (room ID, via) tuples, representing the children of the space, + if `space_id` refers to a space; an empty list otherwise. + * A dictionary of child room ID: `PublicRoomsChunk`s returned over + federation: + https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3publicrooms + These are supposed to include extra `room_type` and `allowed_room_ids` + fields, as described in MSC2946. + + Contains `None` for rooms to which the remote homeserver thinks we do not + have access. + + Raises: + SynapseError: if none of the remote servers provided us with the space's + children. + """ + ( + room, + children_chunks, + inaccessible_children, + ) = await self._federation_client.get_room_hierarchy( + via, space_id, suggested_only=False + ) + + child_events: List[JsonDict] = room["children_state"] + children = [ + (child_event["room_id"], child_event["content"]["via"]) + for child_event in child_events + ] + + room_chunks: Dict[str, Optional[JsonDict]] = {} + room_chunks.update((room_id, None) for room_id in inaccessible_children) + room_chunks.update( + (room_chunk["room_id"], room_chunk) for room_chunk in children_chunks + ) + + return children, room_chunks diff --git a/synapse/handlers/room_summary.py b/synapse/handlers/room_summary.py index fb26ee7ad7bc..d9764a779757 100644 --- a/synapse/handlers/room_summary.py +++ b/synapse/handlers/room_summary.py @@ -1032,7 +1032,7 @@ async def _get_child_events(self, room_id: str) -> Iterable[EventBase]: # filter out any events without a "via" (which implies it has been redacted), # and order to ensure we return stable results. - return sorted(filter(_has_valid_via, events), key=_child_events_comparison_key) + return sorted(filter(has_valid_via, events), key=child_events_comparison_key) async def get_room_summary( self, @@ -1126,7 +1126,7 @@ def as_json(self) -> JsonDict: return result -def _has_valid_via(e: EventBase) -> bool: +def has_valid_via(e: EventBase) -> bool: via = e.content.get("via") if not via or not isinstance(via, Sequence): return False @@ -1149,7 +1149,7 @@ def _is_suggested_child_event(edge_event: EventBase) -> bool: _INVALID_ORDER_CHARS_RE = re.compile(r"[^\x20-\x7E]") -def _child_events_comparison_key( +def child_events_comparison_key( child: EventBase, ) -> Tuple[bool, Optional[str], int, str]: """ diff --git a/synapse/server.py b/synapse/server.py index 877eba6c0803..cd8f260c70f3 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -99,6 +99,7 @@ RoomShutdownHandler, ) from synapse.handlers.room_batch import RoomBatchHandler +from synapse.handlers.room_hierarchy import RoomHierarchyHandler from synapse.handlers.room_list import RoomListHandler from synapse.handlers.room_member import RoomMemberHandler, RoomMemberMasterHandler from synapse.handlers.room_member_worker import RoomMemberWorkerHandler @@ -790,6 +791,10 @@ def get_module_api(self) -> ModuleApi: def get_account_data_handler(self) -> AccountDataHandler: return AccountDataHandler(self) + @cache_in_self + def get_room_hierarchy_handler(self) -> RoomHierarchyHandler: + return RoomHierarchyHandler(self) + @cache_in_self def get_room_summary_handler(self) -> RoomSummaryHandler: return RoomSummaryHandler(self) diff --git a/tests/handlers/test_room_summary.py b/tests/handlers/test_room_summary.py index d3d0bf1ac57e..86beb8ff08d8 100644 --- a/tests/handlers/test_room_summary.py +++ b/tests/handlers/test_room_summary.py @@ -26,7 +26,7 @@ from synapse.api.errors import AuthError, NotFoundError, SynapseError from synapse.api.room_versions import RoomVersions from synapse.events import make_event_from_dict -from synapse.handlers.room_summary import _child_events_comparison_key, _RoomEntry +from synapse.handlers.room_summary import _RoomEntry, child_events_comparison_key from synapse.rest import admin from synapse.rest.client import login, room from synapse.server import HomeServer @@ -46,7 +46,7 @@ def _create_event(room_id: str, order: Optional[Any] = None, origin_server_ts: i def _order(*events): - return sorted(events, key=_child_events_comparison_key) + return sorted(events, key=child_events_comparison_key) class TestSpaceSummarySort(unittest.TestCase): From f0046874106d8eb2d4c796137152878dc6073f1d Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Tue, 16 Nov 2021 13:29:23 +0000 Subject: [PATCH 03/15] Add tests for `RoomHierarchyHandler` --- tests/handlers/test_room_hierarchy.py | 174 ++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/handlers/test_room_hierarchy.py diff --git a/tests/handlers/test_room_hierarchy.py b/tests/handlers/test_room_hierarchy.py new file mode 100644 index 000000000000..31adcd60ff7c --- /dev/null +++ b/tests/handlers/test_room_hierarchy.py @@ -0,0 +1,174 @@ +# Copyright 2021 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 Dict, Optional + +from synapse.api.constants import EventContentFields, EventTypes, RoomTypes +from synapse.rest import admin +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.types import JsonDict + +from tests import unittest + + +class RoomDescendantsTestCase(unittest.HomeserverTestCase): + """Tests iteration over the descendants of a space.""" + + servlets = [ + admin.register_servlets_for_client_rest_resource, + login.register_servlets, + room.register_servlets, + ] + + def prepare(self, reactor, clock, hs: HomeServer): + self.hs = hs + self.handler = self.hs.get_room_hierarchy_handler() + + # Create a user. + self.user = self.register_user("user", "pass") + self.token = self.login("user", "pass") + + # Create a space and a child room. + self.space = self.helper.create_room_as( + self.user, + tok=self.token, + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + self.room = self.helper.create_room_as(self.user, tok=self.token) + self._add_child(self.space, self.room) + + def _add_child( + self, space_id: str, room_id: str, order: Optional[str] = None + ) -> None: + """Adds a room to a space.""" + content: JsonDict = {"via": [self.hs.hostname]} + if order is not None: + content["order"] = order + self.helper.send_state( + space_id, + event_type=EventTypes.SpaceChild, + body=content, + tok=self.token, + state_key=room_id, + ) + + def _create_space(self) -> str: + """Creates a space.""" + return self._create_room( + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + + def _create_room(self, extra_content: Optional[Dict] = None) -> str: + """Creates a room.""" + return self.helper.create_room_as( + self.user, + tok=self.token, + extra_content=extra_content, + ) + + def test_empty_space(self): + """Tests iteration over an empty space.""" + space_id = self._create_space() + + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_room_descendants(space_id) + ) + + self.assertEqual(descendants, [(space_id, [])]) + self.assertEqual(inaccessible_room_ids, []) + + def test_invalid_space(self): + """Tests iteration over an inaccessible space.""" + space_id = f"!invalid:{self.hs.hostname}" + + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_room_descendants(space_id) + ) + + self.assertEqual(descendants, [(space_id, [])]) + self.assertEqual(inaccessible_room_ids, [space_id]) + + def test_invalid_room(self): + """Tests iteration over a space containing an inaccessible room.""" + space_id = self._create_space() + room_id = f"!invalid:{self.hs.hostname}" + self._add_child(space_id, room_id) + + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_room_descendants(space_id) + ) + + self.assertEqual(descendants, [(space_id, []), (room_id, [self.hs.hostname])]) + self.assertEqual(inaccessible_room_ids, [room_id]) + + def test_cycle(self): + """Tests iteration over a cyclic space.""" + # space_id + # - subspace_id + # - space_id + space_id = self._create_space() + subspace_id = self._create_space() + self._add_child(space_id, subspace_id) + self._add_child(subspace_id, space_id) + + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_room_descendants(space_id) + ) + + self.assertEqual( + descendants, [(space_id, []), (subspace_id, [self.hs.hostname])] + ) + self.assertEqual(inaccessible_room_ids, []) + + def test_duplicates(self): + """Tests iteration over a space with repeated rooms.""" + # space_id + # - subspace_id + # - duplicate_room_1_id + # - duplicate_room_2_id + # - room_id + # - duplicate_room_1_id + # - duplicate_room_2_id + space_id = self._create_space() + subspace_id = self._create_space() + room_id = self._create_room() + duplicate_room_1_id = self._create_room() + duplicate_room_2_id = self._create_room() + self._add_child(space_id, subspace_id, order="1") + self._add_child(space_id, duplicate_room_1_id, order="2") + self._add_child(space_id, duplicate_room_2_id, order="3") + self._add_child(subspace_id, duplicate_room_1_id, order="1") + self._add_child(subspace_id, duplicate_room_2_id, order="2") + self._add_child(subspace_id, room_id, order="3") + + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_room_descendants(space_id) + ) + + self.assertEqual( + descendants, + [ + (space_id, []), + (subspace_id, [self.hs.hostname]), + (room_id, [self.hs.hostname]), + (duplicate_room_1_id, [self.hs.hostname]), + (duplicate_room_2_id, [self.hs.hostname]), + ], + ) + self.assertEqual(inaccessible_room_ids, []) From 75be1be9d5d8b70a3ae4e674da313f39d4fa8a42 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 12 Nov 2021 18:36:53 +0000 Subject: [PATCH 04/15] Add admin API to remove a local user from a space --- synapse/rest/admin/__init__.py | 2 + synapse/rest/admin/room_hierarchy.py | 146 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 synapse/rest/admin/room_hierarchy.py diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index d78fe406c40a..b7bc36c17a95 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -45,6 +45,7 @@ NewRegistrationTokenRestServlet, RegistrationTokenRestServlet, ) +from synapse.rest.admin.room_hierarchy import RemoveHierarchyMemberRestServlet from synapse.rest.admin.rooms import ( DeleteRoomStatusByDeleteIdRestServlet, DeleteRoomStatusByRoomIdRestServlet, @@ -253,6 +254,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ListRegistrationTokensRestServlet(hs).register(http_server) NewRegistrationTokenRestServlet(hs).register(http_server) RegistrationTokenRestServlet(hs).register(http_server) + RemoveHierarchyMemberRestServlet(hs).register(http_server) # Some servlets only get registered for the main process. if hs.config.worker.worker_app is None: diff --git a/synapse/rest/admin/room_hierarchy.py b/synapse/rest/admin/room_hierarchy.py new file mode 100644 index 000000000000..16bd902b5aa5 --- /dev/null +++ b/synapse/rest/admin/room_hierarchy.py @@ -0,0 +1,146 @@ +# Copyright 2021 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. +import logging +from typing import TYPE_CHECKING, Dict, List, Tuple + +from synapse.api.constants import EventTypes, JoinRules, Membership +from synapse.api.errors import SynapseError +from synapse.http.servlet import ResolveRoomIdMixin, RestServlet +from synapse.http.site import SynapseRequest +from synapse.rest.admin._base import admin_patterns, assert_user_is_admin +from synapse.storage.state import StateFilter +from synapse.types import JsonDict, UserID, create_requester + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class RemoveHierarchyMemberRestServlet(ResolveRoomIdMixin, RestServlet): + """ + Puppets a local user to remove them from all rooms in a space. + """ + + PATTERNS = admin_patterns( + "/rooms/(?P[^/]+)/hierarchy/members/(?P[^/]+)$" + ) + + def __init__(self, hs: "HomeServer"): + super().__init__(hs) + self._hs = hs + self._auth = hs.get_auth() + self._store = hs.get_datastore() + self._room_member_handler = hs.get_room_member_handler() + self._room_hierarchy_handler = hs.get_room_hierarchy_handler() + + async def on_DELETE( + self, request: SynapseRequest, space_id: str, user_id: str + ) -> Tuple[int, JsonDict]: + """Forces a local user to leave all non-public rooms in a space. + + The space itself is always left, regardless of whether it is public. + + May succeed partially if the user fails to leave some rooms. + + Returns: + A tuple containing the HTTP status code and a JSON dictionary containing: + * `left`: A list of rooms that the user has been made to leave. + * `failed`: A with entries for rooms that could not be fully processed. + The values of the dictionary are lists of failure reasons. + Rooms may appear here if: + * The user failed to leave them for any reason. + * The room is a space that the local homeserver is not in, and so its + full list of child rooms could not be determined. + * The room is inaccessible to the local homeserver, and it is not known + whether the room is a subspace containing further rooms. + * Some combination of the above. + """ + requester = await self._auth.get_user_by_req(request) + await assert_user_is_admin(self._auth, requester.user) + + space_id, _ = await self.resolve_room_id(space_id) + + target_user = UserID.from_string(user_id) + + if not self._hs.is_mine(target_user): + raise SynapseError(400, "This endpoint can only be used with local users") + + # Fetch the list of rooms the target user is currently in + user_rooms = await self._store.get_rooms_for_local_user_where_membership_is( + user_id, [Membership.INVITE, Membership.JOIN, Membership.KNOCK] + ) + user_room_ids = {room.room_id for room in user_rooms} + + # Fetch the list of rooms in the space hierarchy + ( + descendants, + inaccessible_room_ids, + ) = await self._room_hierarchy_handler.get_room_descendants(space_id) + space_room_ids = {space_id} + space_room_ids.update(room_id for room_id, _ in descendants) + + # Determine which rooms to leave by checking join rules. + rooms_to_check = space_room_ids.intersection(user_room_ids) + rooms_to_leave = {space_id} # Always leave the space, even if it is public + state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) + for room_id in rooms_to_check: + current_state_ids = await self._store.get_filtered_current_state_ids( + room_id, state_filter + ) + join_rules_event_id = current_state_ids.get((EventTypes.JoinRules, "")) + if join_rules_event_id is not None: + join_rules_event = await self._store.get_event(join_rules_event_id) + join_rules = join_rules_event.content.get("join_rule") + else: + # The user is invited to or has knocked on a room that is not known + # locally. Assume that such rooms are not public and should be left. + # If it turns out that the room is actually public, then we've not + # actually prevented the user from joining it. + join_rules = None + if join_rules != JoinRules.PUBLIC: + rooms_to_leave.add(room_id) + + # Now start leaving rooms + failures: Dict[str, List[str]] = { + room_id: ["Could not fully explore space or room."] + for room_id in inaccessible_room_ids + } + left_rooms: List[str] = [] + + fake_requester = create_requester( + target_user, authenticated_entity=requester.user.to_string() + ) + + for room_id in rooms_to_leave: + # There is a race condition here where the user may have left or been kicked + # from a room since their list of memberships was fetched. + # `update_membership` will raise if the user is no longer in the room, + # but it's tricky to distinguish from other failure modes. + + try: + await self._room_member_handler.update_membership( + requester=fake_requester, + target=target_user, + room_id=room_id, + action=Membership.LEAVE, + content={}, + ratelimit=False, + require_consent=False, + ) + left_rooms.append(room_id) + except Exception as e: + failures.get(room_id, []).append(str(e)) + + return 200, {"left": left_rooms, "failed": failures} From 3371ec0b85089db7fe33af413d497fb2eb8ede2b Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 12 Nov 2021 18:36:18 +0000 Subject: [PATCH 05/15] Add documentation for new admin API to remove a local user from a space --- docs/SUMMARY.md | 1 + docs/usage/administration/admin_api/spaces.md | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/usage/administration/admin_api/spaces.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index cdedf8bccc28..e71df897971f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -60,6 +60,7 @@ - [Registration Tokens](usage/administration/admin_api/registration_tokens.md) - [Manipulate Room Membership](admin_api/room_membership.md) - [Rooms](admin_api/rooms.md) + - [Spaces](usage/administration/admin_api/spaces.md) - [Server Notices](admin_api/server_notices.md) - [Statistics](admin_api/statistics.md) - [Users](admin_api/user_admin_api.md) diff --git a/docs/usage/administration/admin_api/spaces.md b/docs/usage/administration/admin_api/spaces.md new file mode 100644 index 000000000000..ac917494075b --- /dev/null +++ b/docs/usage/administration/admin_api/spaces.md @@ -0,0 +1,46 @@ +# Spaces API + +This API allows a server administrator to manage spaces. + +## Remove local user + +This API forces a local user to leave all non-public rooms in a space. + +The space itself is always left, regardless of whether it is public. + +May succeed partially if the user fails to leave some rooms. + +The API is: + +``` +DELETE /_synapse/admin/v1/rooms//hierarchy/members/ +``` + +Returning: + +```json +{ + "left": ["!room1:example.net", "!room2:example.net", ...], + "failed": { + "!room3:example.net": [ + "Could not explore space or room fully." + ], + "!room4:example.net": [ + "Failed to leave room." + ], + ... + } +} +``` + +`left`: A list of rooms that the user has been made to leave. + +`failed`: A dictionary with entries for rooms that could not be fully +processed. The values of the dictionary are lists of failure reasons. +Rooms may appear here if: + * The user failed to leave them for any reason. + * The room is a space that the local homeserver is not in, and so + its full list of child rooms could not be determined. + * The room is inaccessible to the local homeserver, and it is not + known whether the room is a subspace containing further rooms. + * Some combination of the above. From b8c228ae982f1fa13a4f7fce786d93dd280c0b14 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 12 Nov 2021 18:37:15 +0000 Subject: [PATCH 06/15] Add tests for admin API to remove a local user from a space --- tests/rest/admin/test_room_hierarchy.py | 260 ++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tests/rest/admin/test_room_hierarchy.py diff --git a/tests/rest/admin/test_room_hierarchy.py b/tests/rest/admin/test_room_hierarchy.py new file mode 100644 index 000000000000..3d2ac01370a5 --- /dev/null +++ b/tests/rest/admin/test_room_hierarchy.py @@ -0,0 +1,260 @@ +# Copyright 2021 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 Dict, Optional, Tuple, Union + +from typing_extensions import Literal + +import synapse.rest.admin +from synapse.api.constants import ( + EventContentFields, + EventTypes, + JoinRules, + Membership, + RestrictedJoinRuleTypes, + RoomTypes, +) +from synapse.api.room_versions import RoomVersions +from synapse.rest.client import login, room +from synapse.types import JsonDict + +from tests import unittest + + +class RemoveHierarchyMemberTestCase(unittest.HomeserverTestCase): + """Tests removal of a user from a space.""" + + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + room.register_servlets, + ] + + def prepare(self, reactor, clock, hs): + self.store = hs.get_datastore() + + # Create users + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + self.space_owner_user = self.register_user("space_owner", "pass") + self.space_owner_user_tok = self.login("space_owner", "pass") + self.target_user = self.register_user("user", "pass") + self.target_user_tok = self.login("user", "pass") + + # Create a space hierarchy for testing: + # space, invite-only + # * subspace, restricted + self.space_id = self._create_space(JoinRules.INVITE) + + # Make the target user a member of the space + self.helper.invite( + self.space_id, + src=self.space_owner_user, + targ=self.target_user, + tok=self.space_owner_user_tok, + ) + self.helper.join(self.space_id, self.target_user, tok=self.target_user_tok) + + self.subspace_id = self._create_space((JoinRules.RESTRICTED, self.space_id)) + self._add_child(self.space_id, self.subspace_id) + + def _add_child(self, space_id: str, room_id: str) -> None: + """Adds a room to a space.""" + self.helper.send_state( + space_id, + event_type=EventTypes.SpaceChild, + body={"via": [self.hs.hostname]}, + tok=self.space_owner_user_tok, + state_key=room_id, + ) + + def _create_space( + self, + join_rules: Union[ + Literal["public", "invite", "knock"], + Tuple[Literal["restricted"], str], + ], + ) -> str: + """Creates a space.""" + return self._create_room( + join_rules, + extra_content={ + "creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE} + }, + ) + + def _create_room( + self, + join_rules: Union[ + Literal["public", "invite", "knock"], + Tuple[Literal["restricted"], str], + ], + extra_content: Optional[Dict] = None, + ) -> str: + """Creates a room.""" + room_id = self.helper.create_room_as( + self.space_owner_user, + room_version=RoomVersions.V8.identifier, + tok=self.space_owner_user_tok, + extra_content=extra_content, + ) + + if isinstance(join_rules, str): + self.helper.send_state( + room_id, + event_type=EventTypes.JoinRules, + body={"join_rule": join_rules}, + tok=self.space_owner_user_tok, + ) + else: + _, space_id = join_rules + self.helper.send_state( + room_id, + event_type=EventTypes.JoinRules, + body={ + "join_rule": JoinRules.RESTRICTED, + "allow": [ + { + "type": RestrictedJoinRuleTypes.ROOM_MEMBERSHIP, + "room_id": space_id, + "via": [self.hs.hostname], + } + ], + }, + tok=self.space_owner_user_tok, + ) + + return room_id + + def _remove_from_space(self, user_id: str) -> JsonDict: + """Removes the given user from the test space.""" + url = f"/_synapse/admin/v1/rooms/{self.space_id}/hierarchy/members/{user_id}" + channel = self.make_request( + "DELETE", + url.encode("ascii"), + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, channel.json_body) + + return channel.json_body + + def test_public_space(self) -> None: + """Tests that the user is removed from the space, even if public.""" + self.helper.send_state( + self.space_id, + event_type=EventTypes.JoinRules, + body={"join_rule": JoinRules.PUBLIC}, + tok=self.space_owner_user_tok, + ) + + response = self._remove_from_space(self.target_user) + + self.assertCountEqual(response["left"], [self.space_id]) + self.assertEqual(response["failed"], {}) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, self.space_id + ) + ) + self.assertEqual(membership, Membership.LEAVE) + + def test_public_room(self) -> None: + """Tests that the user is not removed from public rooms.""" + public_room_id = self._create_room(JoinRules.PUBLIC) + self._add_child(self.subspace_id, public_room_id) + + self.helper.join(public_room_id, self.target_user, tok=self.target_user_tok) + + response = self._remove_from_space(self.target_user) + + self.assertCountEqual(response["left"], [self.space_id]) + self.assertEqual(response["failed"], {}) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, public_room_id + ) + ) + self.assertEqual(membership, Membership.JOIN) + + def test_invited(self) -> None: + """Tests that the user is made to decline invites to rooms in the space.""" + invite_only_room_id = self._create_room(JoinRules.INVITE) + self._add_child(self.subspace_id, invite_only_room_id) + + self.helper.invite( + invite_only_room_id, + src=self.space_owner_user, + targ=self.target_user, + tok=self.space_owner_user_tok, + ) + + response = self._remove_from_space(self.target_user) + + self.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) + self.assertEqual(response["failed"], {}) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, invite_only_room_id + ) + ) + self.assertEqual(membership, Membership.LEAVE) + + def test_invite_only_room(self) -> None: + """Tests that the user is made to leave invite-only rooms.""" + invite_only_room_id = self._create_room(JoinRules.INVITE) + self._add_child(self.subspace_id, invite_only_room_id) + + self.helper.invite( + invite_only_room_id, + src=self.space_owner_user, + targ=self.target_user, + tok=self.space_owner_user_tok, + ) + self.helper.join( + invite_only_room_id, self.target_user, tok=self.target_user_tok + ) + + response = self._remove_from_space(self.target_user) + + self.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) + self.assertEqual(response["failed"], {}) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, invite_only_room_id + ) + ) + self.assertEqual(membership, Membership.LEAVE) + + def test_restricted_room(self) -> None: + """Tests that the user is made to leave restricted rooms.""" + restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id)) + self._add_child(self.subspace_id, restricted_room_id) + self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok) + + response = self._remove_from_space(self.target_user) + + self.assertCountEqual(response["left"], [self.space_id, restricted_room_id]) + self.assertEqual(response["failed"], {}) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, restricted_room_id + ) + ) + self.assertEqual(membership, Membership.LEAVE) From ab89c607024734be9dccf5f7b9e79a2ea5259890 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 12 Nov 2021 18:43:00 +0000 Subject: [PATCH 07/15] Add newsfile --- changelog.d/11358.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11358.feature diff --git a/changelog.d/11358.feature b/changelog.d/11358.feature new file mode 100644 index 000000000000..3ed926556744 --- /dev/null +++ b/changelog.d/11358.feature @@ -0,0 +1 @@ +Add an admin API endpoint to force a local user to leave all non-public rooms in a space. From b43d085472390c77691fbbd86b3e66b4a1a0cb6d Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 12 Nov 2021 19:57:42 +0000 Subject: [PATCH 08/15] Convert strings in `synapse.api.constants` to enums or `Final` This change makes mypy type the constants as `Literal`s instead of `str`s, allowing code of the following form to pass mypy: ```py def do_something( membership: Literal[Membership.JOIN, Membership.LEAVE], ... ): ... do_something(Membership.JOIN, ...) ``` --- changelog.d/11356.misc | 2 +- synapse/api/auth.py | 4 +- synapse/api/constants.py | 235 +++++++++++------------ synapse/event_auth.py | 2 +- synapse/events/validator.py | 4 +- synapse/handlers/auth.py | 5 +- synapse/handlers/federation.py | 2 +- synapse/push/bulk_push_rule_evaluator.py | 5 +- synapse/rest/admin/users.py | 14 +- synapse/rest/client/register.py | 2 +- synapse/state/v1.py | 6 +- synapse/util/enum.py | 27 +++ 12 files changed, 173 insertions(+), 135 deletions(-) create mode 100644 synapse/util/enum.py diff --git a/changelog.d/11356.misc b/changelog.d/11356.misc index 01ce6a306cf4..e93b3ddd38f3 100644 --- a/changelog.d/11356.misc +++ b/changelog.d/11356.misc @@ -1 +1 @@ -Add `Final` annotation to string constants in `synapse.api.constants` so that they get typed as `Literal`s. +Convert most string constants in `synapse.api.constants` into enums, so that they are usable as `Literal`s, and make the rest `Final`. diff --git a/synapse/api/auth.py b/synapse/api/auth.py index 44883c6663ff..3004f06fdd15 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -34,7 +34,7 @@ from synapse.http.site import SynapseRequest from synapse.logging import opentracing as opentracing from synapse.storage.databases.main.registration import TokenLookupResult -from synapse.types import Requester, StateMap, UserID, create_requester +from synapse.types import MutableStateMap, Requester, StateMap, UserID, create_requester from synapse.util.caches.lrucache import LruCache from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry @@ -511,7 +511,7 @@ async def check_can_change_room_list(self, room_id: str, user: UserID) -> bool: room_id, EventTypes.PowerLevels, "" ) - auth_events = {} + auth_events: MutableStateMap[EventBase] = {} if power_level_event: auth_events[(EventTypes.PowerLevels, "")] = power_level_event diff --git a/synapse/api/constants.py b/synapse/api/constants.py index f7d29b431936..e3ed86f57baa 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -19,6 +19,8 @@ from typing_extensions import Final +from synapse.util.enum import StrEnum + # the max size of a (canonical-json-encoded) event MAX_PDU_SIZE = 65536 @@ -37,51 +39,49 @@ MAX_GROUP_ROLEID_LENGTH = 255 -class Membership: - +class Membership(StrEnum): """Represents the membership states of a user in a room.""" - INVITE: Final = "invite" - JOIN: Final = "join" - KNOCK: Final = "knock" - LEAVE: Final = "leave" - BAN: Final = "ban" - LIST: Final = (INVITE, JOIN, KNOCK, LEAVE, BAN) + INVITE = "invite" + JOIN = "join" + KNOCK = "knock" + LEAVE = "leave" + BAN = "ban" -class PresenceState: +class PresenceState(StrEnum): """Represents the presence state of a user.""" - OFFLINE: Final = "offline" - UNAVAILABLE: Final = "unavailable" - ONLINE: Final = "online" - BUSY: Final = "org.matrix.msc3026.busy" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + ONLINE = "online" + BUSY = "org.matrix.msc3026.busy" -class JoinRules: - PUBLIC: Final = "public" - KNOCK: Final = "knock" - INVITE: Final = "invite" - PRIVATE: Final = "private" +class JoinRules(StrEnum): + PUBLIC = "public" + KNOCK = "knock" + INVITE = "invite" + PRIVATE = "private" # As defined for MSC3083. - RESTRICTED: Final = "restricted" + RESTRICTED = "restricted" -class RestrictedJoinRuleTypes: +class RestrictedJoinRuleTypes(StrEnum): """Understood types for the allow rules in restricted join rules.""" - ROOM_MEMBERSHIP: Final = "m.room_membership" + ROOM_MEMBERSHIP = "m.room_membership" -class LoginType: - PASSWORD: Final = "m.login.password" - EMAIL_IDENTITY: Final = "m.login.email.identity" - MSISDN: Final = "m.login.msisdn" - RECAPTCHA: Final = "m.login.recaptcha" - TERMS: Final = "m.login.terms" - SSO: Final = "m.login.sso" - DUMMY: Final = "m.login.dummy" - REGISTRATION_TOKEN: Final = "org.matrix.msc3231.login.registration_token" +class LoginType(StrEnum): + PASSWORD = "m.login.password" + EMAIL_IDENTITY = "m.login.email.identity" + MSISDN = "m.login.msisdn" + RECAPTCHA = "m.login.recaptcha" + TERMS = "m.login.terms" + SSO = "m.login.sso" + DUMMY = "m.login.dummy" + REGISTRATION_TOKEN = "org.matrix.msc3231.login.registration_token" # This is used in the `type` parameter for /register when called by @@ -89,169 +89,168 @@ class LoginType: APP_SERVICE_REGISTRATION_TYPE: Final = "m.login.application_service" -class EventTypes: - Member: Final = "m.room.member" - Create: Final = "m.room.create" - Tombstone: Final = "m.room.tombstone" - JoinRules: Final = "m.room.join_rules" - PowerLevels: Final = "m.room.power_levels" - Aliases: Final = "m.room.aliases" - Redaction: Final = "m.room.redaction" - ThirdPartyInvite: Final = "m.room.third_party_invite" - RelatedGroups: Final = "m.room.related_groups" - - RoomHistoryVisibility: Final = "m.room.history_visibility" - CanonicalAlias: Final = "m.room.canonical_alias" - Encrypted: Final = "m.room.encrypted" - RoomAvatar: Final = "m.room.avatar" - RoomEncryption: Final = "m.room.encryption" - GuestAccess: Final = "m.room.guest_access" +class EventTypes(StrEnum): + Member = "m.room.member" + Create = "m.room.create" + Tombstone = "m.room.tombstone" + JoinRules = "m.room.join_rules" + PowerLevels = "m.room.power_levels" + Aliases = "m.room.aliases" + Redaction = "m.room.redaction" + ThirdPartyInvite = "m.room.third_party_invite" + RelatedGroups = "m.room.related_groups" + + RoomHistoryVisibility = "m.room.history_visibility" + CanonicalAlias = "m.room.canonical_alias" + Encrypted = "m.room.encrypted" + RoomAvatar = "m.room.avatar" + RoomEncryption = "m.room.encryption" + GuestAccess = "m.room.guest_access" # These are used for validation - Message: Final = "m.room.message" - Topic: Final = "m.room.topic" - Name: Final = "m.room.name" + Message = "m.room.message" + Topic = "m.room.topic" + Name = "m.room.name" - ServerACL: Final = "m.room.server_acl" - Pinned: Final = "m.room.pinned_events" + ServerACL = "m.room.server_acl" + Pinned = "m.room.pinned_events" - Retention: Final = "m.room.retention" + Retention = "m.room.retention" - Dummy: Final = "org.matrix.dummy_event" + Dummy = "org.matrix.dummy_event" - SpaceChild: Final = "m.space.child" - SpaceParent: Final = "m.space.parent" + SpaceChild = "m.space.child" + SpaceParent = "m.space.parent" - MSC2716_INSERTION: Final = "org.matrix.msc2716.insertion" - MSC2716_BATCH: Final = "org.matrix.msc2716.batch" - MSC2716_MARKER: Final = "org.matrix.msc2716.marker" + MSC2716_INSERTION = "org.matrix.msc2716.insertion" + MSC2716_BATCH = "org.matrix.msc2716.batch" + MSC2716_MARKER = "org.matrix.msc2716.marker" -class ToDeviceEventTypes: - RoomKeyRequest: Final = "m.room_key_request" +class ToDeviceEventTypes(StrEnum): + RoomKeyRequest = "m.room_key_request" -class DeviceKeyAlgorithms: +class DeviceKeyAlgorithms(StrEnum): """Spec'd algorithms for the generation of per-device keys""" - ED25519: Final = "ed25519" - CURVE25519: Final = "curve25519" - SIGNED_CURVE25519: Final = "signed_curve25519" + ED25519 = "ed25519" + CURVE25519 = "curve25519" + SIGNED_CURVE25519 = "signed_curve25519" -class EduTypes: - Presence: Final = "m.presence" +class EduTypes(StrEnum): + Presence = "m.presence" -class RejectedReason: - AUTH_ERROR: Final = "auth_error" +class RejectedReason(StrEnum): + AUTH_ERROR = "auth_error" -class RoomCreationPreset: - PRIVATE_CHAT: Final = "private_chat" - PUBLIC_CHAT: Final = "public_chat" - TRUSTED_PRIVATE_CHAT: Final = "trusted_private_chat" +class RoomCreationPreset(StrEnum): + PRIVATE_CHAT = "private_chat" + PUBLIC_CHAT = "public_chat" + TRUSTED_PRIVATE_CHAT = "trusted_private_chat" -class ThirdPartyEntityKind: - USER: Final = "user" - LOCATION: Final = "location" +class ThirdPartyEntityKind(StrEnum): + USER = "user" + LOCATION = "location" ServerNoticeMsgType: Final = "m.server_notice" ServerNoticeLimitReached: Final = "m.server_notice.usage_limit_reached" -class UserTypes: +class UserTypes(StrEnum): """Allows for user type specific behaviour. With the benefit of hindsight 'admin' and 'guest' users should also be UserTypes. Normal users are type None """ - SUPPORT: Final = "support" - BOT: Final = "bot" - ALL_USER_TYPES: Final = (SUPPORT, BOT) + SUPPORT = "support" + BOT = "bot" -class RelationTypes: +class RelationTypes(StrEnum): """The types of relations known to this server.""" - ANNOTATION: Final = "m.annotation" - REPLACE: Final = "m.replace" - REFERENCE: Final = "m.reference" - THREAD: Final = "io.element.thread" + ANNOTATION = "m.annotation" + REPLACE = "m.replace" + REFERENCE = "m.reference" + THREAD = "io.element.thread" -class LimitBlockingTypes: +class LimitBlockingTypes(StrEnum): """Reasons that a server may be blocked""" - MONTHLY_ACTIVE_USER: Final = "monthly_active_user" - HS_DISABLED: Final = "hs_disabled" + MONTHLY_ACTIVE_USER = "monthly_active_user" + HS_DISABLED = "hs_disabled" -class EventContentFields: +class EventContentFields(StrEnum): """Fields found in events' content, regardless of type.""" # Labels for the event, cf https://github.com/matrix-org/matrix-doc/pull/2326 - LABELS: Final = "org.matrix.labels" + LABELS = "org.matrix.labels" # Timestamp to delete the event after # cf https://github.com/matrix-org/matrix-doc/pull/2228 - SELF_DESTRUCT_AFTER: Final = "org.matrix.self_destruct_after" + SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after" # cf https://github.com/matrix-org/matrix-doc/pull/1772 - ROOM_TYPE: Final = "type" + ROOM_TYPE = "type" # Whether a room can federate. - FEDERATE: Final = "m.federate" + FEDERATE = "m.federate" # The creator of the room, as used in `m.room.create` events. - ROOM_CREATOR: Final = "creator" + ROOM_CREATOR = "creator" # Used in m.room.guest_access events. - GUEST_ACCESS: Final = "guest_access" + GUEST_ACCESS = "guest_access" # Used on normal messages to indicate they were historically imported after the fact - MSC2716_HISTORICAL: Final = "org.matrix.msc2716.historical" + MSC2716_HISTORICAL = "org.matrix.msc2716.historical" # For "insertion" events to indicate what the next batch ID should be in # order to connect to it - MSC2716_NEXT_BATCH_ID: Final = "org.matrix.msc2716.next_batch_id" + MSC2716_NEXT_BATCH_ID = "org.matrix.msc2716.next_batch_id" # Used on "batch" events to indicate which insertion event it connects to - MSC2716_BATCH_ID: Final = "org.matrix.msc2716.batch_id" + MSC2716_BATCH_ID = "org.matrix.msc2716.batch_id" # For "marker" events - MSC2716_MARKER_INSERTION: Final = "org.matrix.msc2716.marker.insertion" + MSC2716_MARKER_INSERTION = "org.matrix.msc2716.marker.insertion" # The authorising user for joining a restricted room. - AUTHORISING_USER: Final = "join_authorised_via_users_server" + AUTHORISING_USER = "join_authorised_via_users_server" -class RoomTypes: +class RoomTypes(StrEnum): """Understood values of the room_type field of m.room.create events.""" - SPACE: Final = "m.space" + SPACE = "m.space" -class RoomEncryptionAlgorithms: - MEGOLM_V1_AES_SHA2: Final = "m.megolm.v1.aes-sha2" - DEFAULT: Final = MEGOLM_V1_AES_SHA2 +class RoomEncryptionAlgorithms(StrEnum): + MEGOLM_V1_AES_SHA2 = "m.megolm.v1.aes-sha2" + DEFAULT = MEGOLM_V1_AES_SHA2 -class AccountDataTypes: - DIRECT: Final = "m.direct" - IGNORED_USER_LIST: Final = "m.ignored_user_list" +class AccountDataTypes(StrEnum): + DIRECT = "m.direct" + IGNORED_USER_LIST = "m.ignored_user_list" -class HistoryVisibility: - INVITED: Final = "invited" - JOINED: Final = "joined" - SHARED: Final = "shared" - WORLD_READABLE: Final = "world_readable" +class HistoryVisibility(StrEnum): + INVITED = "invited" + JOINED = "joined" + SHARED = "shared" + WORLD_READABLE = "world_readable" -class GuestAccess: - CAN_JOIN: Final = "can_join" +class GuestAccess(StrEnum): + CAN_JOIN = "can_join" # anything that is not "can_join" is considered "forbidden", but for completeness: - FORBIDDEN: Final = "forbidden" + FORBIDDEN = "forbidden" -class ReadReceiptEventFields: - MSC2285_HIDDEN: Final = "org.matrix.msc2285.hidden" +class ReadReceiptEventFields(StrEnum): + MSC2285_HIDDEN = "org.matrix.msc2285.hidden" diff --git a/synapse/event_auth.py b/synapse/event_auth.py index e88596169862..39fb4a364172 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -840,7 +840,7 @@ def get_public_keys(invite_event: EventBase) -> List[Dict[str, Any]]: def auth_types_for_event( room_version: RoomVersion, event: Union[EventBase, EventBuilder] -) -> Set[Tuple[str, str]]: +) -> Set[Tuple[EventTypes, str]]: """Given an event, return a list of (EventType, StateKey) that may be needed to auth the event. The returned list may be a superset of what would actually be required depending on the full state of the room. diff --git a/synapse/events/validator.py b/synapse/events/validator.py index cf8693496846..f6fa96ba3cd7 100644 --- a/synapse/events/validator.py +++ b/synapse/events/validator.py @@ -189,7 +189,9 @@ def validate_builder(self, event: Union[EventBase, EventBuilder]) -> None: if "membership" not in event.content: raise SynapseError(400, "Content has not membership key") - if event.content["membership"] not in Membership.LIST: + try: + Membership(event.content["membership"]) + except ValueError: raise SynapseError(400, "Invalid membership key") self._ensure_state_event(event) diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index b62e13b725cf..ab734134ee02 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -28,6 +28,7 @@ List, Mapping, Optional, + Set, Tuple, Type, Union, @@ -382,7 +383,7 @@ def get_new_session_data() -> JsonDict: async def _get_available_ui_auth_types(self, user: UserID) -> Iterable[str]: """Get a list of the authentication types this user can use""" - ui_auth_types = set() + ui_auth_types: Set[str] = set() # if the HS supports password auth, and the user has a non-null password, we # support password auth @@ -735,7 +736,7 @@ def _auth_dict_for_flows( for f in flows: public_flows.append(f) - get_params = { + get_params: Dict[str, Any] = { LoginType.RECAPTCHA: self._get_params_recaptcha, LoginType.TERMS: self._get_params_terms, } diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 3112cc88b1cc..4f543973f24b 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -686,7 +686,7 @@ async def on_make_join_request( ) raise NotFoundError("Not an active room on this server") - event_content = {"membership": Membership.JOIN} + event_content: JsonDict = {"membership": Membership.JOIN} # If the current room is using restricted join rules, additional information # may need to be included in the event content in order to efficiently diff --git a/synapse/push/bulk_push_rule_evaluator.py b/synapse/push/bulk_push_rule_evaluator.py index 009d8e77b05b..86377fbb1df3 100644 --- a/synapse/push/bulk_push_rule_evaluator.py +++ b/synapse/push/bulk_push_rule_evaluator.py @@ -24,6 +24,7 @@ from synapse.events import EventBase from synapse.events.snapshot import EventContext from synapse.state import POWER_KEY +from synapse.types import StateMap from synapse.util.async_helpers import Linearizer from synapse.util.caches import CacheMetric, register_cache from synapse.util.caches.descriptors import lru_cache @@ -170,7 +171,9 @@ async def _get_power_levels_and_sender_level( if pl_event_id: # fastpath: if there's a power level event, that's all we need, and # not having a power level event is an extreme edge case - auth_events = {POWER_KEY: await self.store.get_event(pl_event_id)} + auth_events: StateMap[EventBase] = { + POWER_KEY: await self.store.get_event(pl_event_id) + } else: auth_events_ids = self._event_auth_handler.compute_auth_events( event, prev_state_ids, for_verification=False diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 23a8bf1fdbcc..3f782258827b 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -209,8 +209,11 @@ async def on_PUT( assert_params_in_dict(external_id, ["auth_provider", "external_id"]) user_type = body.get("user_type", None) - if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES: - raise SynapseError(400, "Invalid user type") + if user_type is not None: + try: + UserTypes(user_type) + except ValueError: + raise SynapseError(400, "Invalid user type") set_admin_to = body.get("admin", False) if not isinstance(set_admin_to, bool): @@ -481,8 +484,11 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: user_type = body.get("user_type", None) displayname = body.get("displayname", None) - if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES: - raise SynapseError(400, "Invalid user type") + if user_type is not None: + try: + UserTypes(user_type) + except ValueError: + raise SynapseError(400, "Invalid user type") if "mac" not in body: raise SynapseError(400, "mac must be specified", errcode=Codes.BAD_JSON) diff --git a/synapse/rest/client/register.py b/synapse/rest/client/register.py index bf3cb3414677..9293f4f3d5f4 100644 --- a/synapse/rest/client/register.py +++ b/synapse/rest/client/register.py @@ -876,7 +876,7 @@ def _calculate_registration_flows( "validation is not configured" ) - flows = [] + flows: List[List[str]] = [] # only support 3PIDless registration if no 3PIDs are required if not require_email and not require_msisdn: diff --git a/synapse/state/v1.py b/synapse/state/v1.py index 6edadea550d2..bfa676d1ac96 100644 --- a/synapse/state/v1.py +++ b/synapse/state/v1.py @@ -204,7 +204,7 @@ def _create_auth_events_from_maps( Returns: A map from state key to event id. """ - auth_events = {} + auth_events: MutableStateMap[str] = {} for event_ids in conflicted_state.values(): for event_id in event_ids: if event_id in state_map: @@ -269,7 +269,7 @@ def _resolve_state_events( 3. memberships 4. other events. """ - resolved_state = {} + resolved_state: MutableStateMap[EventBase] = {} if POWER_KEY in conflicted_state: events = conflicted_state[POWER_KEY] logger.debug("Resolving conflicted power levels %r", events) @@ -316,7 +316,7 @@ def _resolve_auth_events( for key in event_auth.auth_types_for_event(room_version, event) } - new_auth_events = {} + new_auth_events: MutableStateMap[EventBase] = {} for key in auth_keys: auth_event = auth_events.get(key, None) if auth_event: diff --git a/synapse/util/enum.py b/synapse/util/enum.py new file mode 100644 index 000000000000..c7a7d1843729 --- /dev/null +++ b/synapse/util/enum.py @@ -0,0 +1,27 @@ +# Copyright 2021 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 enum import Enum + + +class StrEnum(str, Enum): + """Enum where members are also (and must be) strings + + Similar to `IntEnum` except for strings. + Comparison and JSON serialization work as expected. + Interchangeable with regular strings when used as dictionary keys. + """ + + __str__ = str.__str__ + __format__ = str.__format__ From 8627a456e314d4777d8dcbeb7da1fb3624ebfdf2 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 15:40:29 +0000 Subject: [PATCH 09/15] Refer to "spaces" instead of "rooms" --- .../{room_hierarchy.py => space_hierarchy.py} | 23 +++++++++++-------- synapse/rest/admin/__init__.py | 4 ++-- .../admin/{room_hierarchy.py => space.py} | 6 ++--- synapse/server.py | 6 ++--- ...m_hierarchy.py => test_space_hierarchy.py} | 14 +++++------ .../{test_room_hierarchy.py => test_space.py} | 2 +- 6 files changed, 29 insertions(+), 26 deletions(-) rename synapse/handlers/{room_hierarchy.py => space_hierarchy.py} (94%) rename synapse/rest/admin/{room_hierarchy.py => space.py} (96%) rename tests/handlers/{test_room_hierarchy.py => test_space_hierarchy.py} (93%) rename tests/rest/admin/{test_room_hierarchy.py => test_space.py} (99%) diff --git a/synapse/handlers/room_hierarchy.py b/synapse/handlers/space_hierarchy.py similarity index 94% rename from synapse/handlers/room_hierarchy.py rename to synapse/handlers/space_hierarchy.py index 54cdc130bdbc..619059b55efa 100644 --- a/synapse/handlers/room_hierarchy.py +++ b/synapse/handlers/space_hierarchy.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -class RoomHierarchyHandler: +class SpaceHierarchyHandler: """Provides methods for walking over space hierarchies. Also see `RoomSummaryHandler`, which has similar functionality. @@ -49,7 +49,7 @@ def __init__(self, hs: "HomeServer"): self._server_name = hs.hostname - async def get_room_descendants( + async def get_space_descendants( self, space_id: str, via: Optional[Iterable[str]] = None ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]: """Gets the children of a space, recursively. @@ -75,6 +75,7 @@ async def get_room_descendants( todo: List[Tuple[str, Iterable[str], Mapping[str, Optional[JsonDict]]]] = [ (space_id, via, {}) ] + # [(room ID, via)] descendants: List[Tuple[str, Iterable[str]]] = [] seen = {space_id} @@ -89,7 +90,9 @@ async def get_room_descendants( is_in_room, children, federation_room_chunks, - ) = await self._get_room_children(space_id, via, federation_room_chunks) + ) = await self._get_space_children( + space_id, via, federation_room_chunks + ) except SynapseError: # Could not list children over federation inaccessible_room_ids.append(space_id) @@ -103,7 +106,7 @@ async def get_room_descendants( # Queue up the child for processing. # The child may not actually be a space, but that's checked by - # `_get_room_children`. + # `_get_space_children`. todo.append((child_room_id, child_via, federation_room_chunks)) # Children were retrieved over federation, which is not guaranteed to be @@ -113,7 +116,7 @@ async def get_room_descendants( return descendants, inaccessible_room_ids - async def _get_room_children( + async def _get_space_children( self, space_id: str, via: Optional[Iterable[str]] = None, @@ -127,7 +130,7 @@ async def _get_room_children( space_id: The room ID of the space. via: A list of servers which may know about the space. federation_room_chunks: A cache of room chunks previously returned by - `_get_room_children` that may be used to skip federation requests for + `_get_space_children` that may be used to skip federation requests for inaccessible or non-space rooms. Returns: @@ -156,7 +159,7 @@ async def _get_room_children( is_in_room = await self._store.is_host_joined(space_id, self._server_name) if is_in_room: - children = await self._get_room_children_local(space_id) + children = await self._get_space_children_local(space_id) return True, children, {} else: # Check the room chunks previously returned over federation to see if we @@ -175,10 +178,10 @@ async def _get_room_children( # `space_id` is not a space according to federation. return False, [], {} - children, room_chunks = await self._get_room_children_remote(space_id, via) + children, room_chunks = await self._get_space_children_remote(space_id, via) return False, children, room_chunks - async def _get_room_children_local( + async def _get_space_children_local( self, space_id: str ) -> Sequence[Tuple[str, Iterable[str]]]: """Gets the direct children of a space that the local homeserver is in. @@ -225,7 +228,7 @@ async def _get_room_children_local( child_events.sort(key=child_events_comparison_key) return [(event.state_key, event.content["via"]) for event in child_events] - async def _get_room_children_remote( + async def _get_space_children_remote( self, space_id: str, via: Iterable[str] ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]]]: """Gets the direct children of a space over federation. diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index b7bc36c17a95..36cfd1e4e258 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -45,7 +45,6 @@ NewRegistrationTokenRestServlet, RegistrationTokenRestServlet, ) -from synapse.rest.admin.room_hierarchy import RemoveHierarchyMemberRestServlet from synapse.rest.admin.rooms import ( DeleteRoomStatusByDeleteIdRestServlet, DeleteRoomStatusByRoomIdRestServlet, @@ -60,6 +59,7 @@ RoomStateRestServlet, ) from synapse.rest.admin.server_notice_servlet import SendServerNoticeServlet +from synapse.rest.admin.space import RemoveSpaceMemberRestServlet from synapse.rest.admin.statistics import UserMediaStatisticsRestServlet from synapse.rest.admin.username_available import UsernameAvailableRestServlet from synapse.rest.admin.users import ( @@ -254,7 +254,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ListRegistrationTokensRestServlet(hs).register(http_server) NewRegistrationTokenRestServlet(hs).register(http_server) RegistrationTokenRestServlet(hs).register(http_server) - RemoveHierarchyMemberRestServlet(hs).register(http_server) + RemoveSpaceMemberRestServlet(hs).register(http_server) # Some servlets only get registered for the main process. if hs.config.worker.worker_app is None: diff --git a/synapse/rest/admin/room_hierarchy.py b/synapse/rest/admin/space.py similarity index 96% rename from synapse/rest/admin/room_hierarchy.py rename to synapse/rest/admin/space.py index 16bd902b5aa5..952240d01f8d 100644 --- a/synapse/rest/admin/room_hierarchy.py +++ b/synapse/rest/admin/space.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) -class RemoveHierarchyMemberRestServlet(ResolveRoomIdMixin, RestServlet): +class RemoveSpaceMemberRestServlet(ResolveRoomIdMixin, RestServlet): """ Puppets a local user to remove them from all rooms in a space. """ @@ -43,7 +43,7 @@ def __init__(self, hs: "HomeServer"): self._auth = hs.get_auth() self._store = hs.get_datastore() self._room_member_handler = hs.get_room_member_handler() - self._room_hierarchy_handler = hs.get_room_hierarchy_handler() + self._space_hierarchy_handler = hs.get_space_hierarchy_handler() async def on_DELETE( self, request: SynapseRequest, space_id: str, user_id: str @@ -87,7 +87,7 @@ async def on_DELETE( ( descendants, inaccessible_room_ids, - ) = await self._room_hierarchy_handler.get_room_descendants(space_id) + ) = await self._space_hierarchy_handler.get_space_descendants(space_id) space_room_ids = {space_id} space_room_ids.update(room_id for room_id, _ in descendants) diff --git a/synapse/server.py b/synapse/server.py index cd8f260c70f3..9b7dba013cbf 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -99,7 +99,6 @@ RoomShutdownHandler, ) from synapse.handlers.room_batch import RoomBatchHandler -from synapse.handlers.room_hierarchy import RoomHierarchyHandler from synapse.handlers.room_list import RoomListHandler from synapse.handlers.room_member import RoomMemberHandler, RoomMemberMasterHandler from synapse.handlers.room_member_worker import RoomMemberWorkerHandler @@ -107,6 +106,7 @@ from synapse.handlers.search import SearchHandler from synapse.handlers.send_email import SendEmailHandler from synapse.handlers.set_password import SetPasswordHandler +from synapse.handlers.space_hierarchy import SpaceHierarchyHandler from synapse.handlers.sso import SsoHandler from synapse.handlers.stats import StatsHandler from synapse.handlers.sync import SyncHandler @@ -792,8 +792,8 @@ def get_account_data_handler(self) -> AccountDataHandler: return AccountDataHandler(self) @cache_in_self - def get_room_hierarchy_handler(self) -> RoomHierarchyHandler: - return RoomHierarchyHandler(self) + def get_space_hierarchy_handler(self) -> SpaceHierarchyHandler: + return SpaceHierarchyHandler(self) @cache_in_self def get_room_summary_handler(self) -> RoomSummaryHandler: diff --git a/tests/handlers/test_room_hierarchy.py b/tests/handlers/test_space_hierarchy.py similarity index 93% rename from tests/handlers/test_room_hierarchy.py rename to tests/handlers/test_space_hierarchy.py index 31adcd60ff7c..8e46b2c93ec6 100644 --- a/tests/handlers/test_room_hierarchy.py +++ b/tests/handlers/test_space_hierarchy.py @@ -23,7 +23,7 @@ from tests import unittest -class RoomDescendantsTestCase(unittest.HomeserverTestCase): +class SpaceDescendantsTestCase(unittest.HomeserverTestCase): """Tests iteration over the descendants of a space.""" servlets = [ @@ -34,7 +34,7 @@ class RoomDescendantsTestCase(unittest.HomeserverTestCase): def prepare(self, reactor, clock, hs: HomeServer): self.hs = hs - self.handler = self.hs.get_room_hierarchy_handler() + self.handler = self.hs.get_space_hierarchy_handler() # Create a user. self.user = self.register_user("user", "pass") @@ -87,7 +87,7 @@ def test_empty_space(self): space_id = self._create_space() descendants, inaccessible_room_ids = self.get_success( - self.handler.get_room_descendants(space_id) + self.handler.get_space_descendants(space_id) ) self.assertEqual(descendants, [(space_id, [])]) @@ -98,7 +98,7 @@ def test_invalid_space(self): space_id = f"!invalid:{self.hs.hostname}" descendants, inaccessible_room_ids = self.get_success( - self.handler.get_room_descendants(space_id) + self.handler.get_space_descendants(space_id) ) self.assertEqual(descendants, [(space_id, [])]) @@ -111,7 +111,7 @@ def test_invalid_room(self): self._add_child(space_id, room_id) descendants, inaccessible_room_ids = self.get_success( - self.handler.get_room_descendants(space_id) + self.handler.get_space_descendants(space_id) ) self.assertEqual(descendants, [(space_id, []), (room_id, [self.hs.hostname])]) @@ -128,7 +128,7 @@ def test_cycle(self): self._add_child(subspace_id, space_id) descendants, inaccessible_room_ids = self.get_success( - self.handler.get_room_descendants(space_id) + self.handler.get_space_descendants(space_id) ) self.assertEqual( @@ -158,7 +158,7 @@ def test_duplicates(self): self._add_child(subspace_id, room_id, order="3") descendants, inaccessible_room_ids = self.get_success( - self.handler.get_room_descendants(space_id) + self.handler.get_space_descendants(space_id) ) self.assertEqual( diff --git a/tests/rest/admin/test_room_hierarchy.py b/tests/rest/admin/test_space.py similarity index 99% rename from tests/rest/admin/test_room_hierarchy.py rename to tests/rest/admin/test_space.py index 3d2ac01370a5..59c6c3b28474 100644 --- a/tests/rest/admin/test_room_hierarchy.py +++ b/tests/rest/admin/test_space.py @@ -32,7 +32,7 @@ from tests import unittest -class RemoveHierarchyMemberTestCase(unittest.HomeserverTestCase): +class RemoveSpaceMemberTestCase(unittest.HomeserverTestCase): """Tests removal of a user from a space.""" servlets = [ From f77da61ce89306ecad2d560623dd147ef967cca7 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 15:42:47 +0000 Subject: [PATCH 10/15] Add missing type hints to new tests --- tests/handlers/test_space_hierarchy.py | 5 ++++- tests/rest/admin/test_space.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/handlers/test_space_hierarchy.py b/tests/handlers/test_space_hierarchy.py index 8e46b2c93ec6..548173d1dbf4 100644 --- a/tests/handlers/test_space_hierarchy.py +++ b/tests/handlers/test_space_hierarchy.py @@ -14,11 +14,14 @@ from typing import Dict, Optional +from twisted.test.proto_helpers import MemoryReactor + from synapse.api.constants import EventContentFields, EventTypes, RoomTypes from synapse.rest import admin from synapse.rest.client import login, room from synapse.server import HomeServer from synapse.types import JsonDict +from synapse.util import Clock from tests import unittest @@ -32,7 +35,7 @@ class SpaceDescendantsTestCase(unittest.HomeserverTestCase): room.register_servlets, ] - def prepare(self, reactor, clock, hs: HomeServer): + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer): self.hs = hs self.handler = self.hs.get_space_hierarchy_handler() diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py index 59c6c3b28474..46267b0b899b 100644 --- a/tests/rest/admin/test_space.py +++ b/tests/rest/admin/test_space.py @@ -16,6 +16,8 @@ from typing_extensions import Literal +from twisted.test.proto_helpers import MemoryReactor + import synapse.rest.admin from synapse.api.constants import ( EventContentFields, @@ -27,7 +29,9 @@ ) from synapse.api.room_versions import RoomVersions from synapse.rest.client import login, room +from synapse.server import HomeServer from synapse.types import JsonDict +from synapse.util import Clock from tests import unittest @@ -41,7 +45,7 @@ class RemoveSpaceMemberTestCase(unittest.HomeserverTestCase): room.register_servlets, ] - def prepare(self, reactor, clock, hs): + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer): self.store = hs.get_datastore() # Create users From b105beafdb7f807479dee6e0452a0a89a05f1913 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 16:00:00 +0000 Subject: [PATCH 11/15] Fix bug/redundant code to do with handling of the root space id --- synapse/rest/admin/space.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/synapse/rest/admin/space.py b/synapse/rest/admin/space.py index 952240d01f8d..eab174d90da8 100644 --- a/synapse/rest/admin/space.py +++ b/synapse/rest/admin/space.py @@ -88,12 +88,11 @@ async def on_DELETE( descendants, inaccessible_room_ids, ) = await self._space_hierarchy_handler.get_space_descendants(space_id) - space_room_ids = {space_id} - space_room_ids.update(room_id for room_id, _ in descendants) + space_room_ids = {room_id for room_id, _ in descendants} - # Determine which rooms to leave by checking join rules. + # Determine which rooms to leave by checking join rules rooms_to_check = space_room_ids.intersection(user_room_ids) - rooms_to_leave = {space_id} # Always leave the space, even if it is public + rooms_to_leave = set() state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) for room_id in rooms_to_check: current_state_ids = await self._store.get_filtered_current_state_ids( @@ -112,6 +111,10 @@ async def on_DELETE( if join_rules != JoinRules.PUBLIC: rooms_to_leave.add(room_id) + # Always leave the space, even if it is public + if space_id in user_room_ids: + rooms_to_leave.add(space_id) + # Now start leaving rooms failures: Dict[str, List[str]] = { room_id: ["Could not fully explore space or room."] From e5751a6350a796ff5229a9cf29042fc83b15ca86 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 16:06:38 +0000 Subject: [PATCH 12/15] Revert "Convert strings in `synapse.api.constants` to enums or `Final`" This reverts commit b43d085472390c77691fbbd86b3e66b4a1a0cb6d. --- changelog.d/11356.misc | 2 +- synapse/api/auth.py | 4 +- synapse/api/constants.py | 235 ++++++++++++----------- synapse/event_auth.py | 2 +- synapse/events/validator.py | 4 +- synapse/handlers/auth.py | 5 +- synapse/handlers/federation.py | 2 +- synapse/push/bulk_push_rule_evaluator.py | 5 +- synapse/rest/admin/users.py | 14 +- synapse/rest/client/register.py | 2 +- synapse/state/v1.py | 6 +- synapse/util/enum.py | 27 --- 12 files changed, 135 insertions(+), 173 deletions(-) delete mode 100644 synapse/util/enum.py diff --git a/changelog.d/11356.misc b/changelog.d/11356.misc index e93b3ddd38f3..01ce6a306cf4 100644 --- a/changelog.d/11356.misc +++ b/changelog.d/11356.misc @@ -1 +1 @@ -Convert most string constants in `synapse.api.constants` into enums, so that they are usable as `Literal`s, and make the rest `Final`. +Add `Final` annotation to string constants in `synapse.api.constants` so that they get typed as `Literal`s. diff --git a/synapse/api/auth.py b/synapse/api/auth.py index 3004f06fdd15..44883c6663ff 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -34,7 +34,7 @@ from synapse.http.site import SynapseRequest from synapse.logging import opentracing as opentracing from synapse.storage.databases.main.registration import TokenLookupResult -from synapse.types import MutableStateMap, Requester, StateMap, UserID, create_requester +from synapse.types import Requester, StateMap, UserID, create_requester from synapse.util.caches.lrucache import LruCache from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry @@ -511,7 +511,7 @@ async def check_can_change_room_list(self, room_id: str, user: UserID) -> bool: room_id, EventTypes.PowerLevels, "" ) - auth_events: MutableStateMap[EventBase] = {} + auth_events = {} if power_level_event: auth_events[(EventTypes.PowerLevels, "")] = power_level_event diff --git a/synapse/api/constants.py b/synapse/api/constants.py index e3ed86f57baa..f7d29b431936 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -19,8 +19,6 @@ from typing_extensions import Final -from synapse.util.enum import StrEnum - # the max size of a (canonical-json-encoded) event MAX_PDU_SIZE = 65536 @@ -39,49 +37,51 @@ MAX_GROUP_ROLEID_LENGTH = 255 -class Membership(StrEnum): +class Membership: + """Represents the membership states of a user in a room.""" - INVITE = "invite" - JOIN = "join" - KNOCK = "knock" - LEAVE = "leave" - BAN = "ban" + INVITE: Final = "invite" + JOIN: Final = "join" + KNOCK: Final = "knock" + LEAVE: Final = "leave" + BAN: Final = "ban" + LIST: Final = (INVITE, JOIN, KNOCK, LEAVE, BAN) -class PresenceState(StrEnum): +class PresenceState: """Represents the presence state of a user.""" - OFFLINE = "offline" - UNAVAILABLE = "unavailable" - ONLINE = "online" - BUSY = "org.matrix.msc3026.busy" + OFFLINE: Final = "offline" + UNAVAILABLE: Final = "unavailable" + ONLINE: Final = "online" + BUSY: Final = "org.matrix.msc3026.busy" -class JoinRules(StrEnum): - PUBLIC = "public" - KNOCK = "knock" - INVITE = "invite" - PRIVATE = "private" +class JoinRules: + PUBLIC: Final = "public" + KNOCK: Final = "knock" + INVITE: Final = "invite" + PRIVATE: Final = "private" # As defined for MSC3083. - RESTRICTED = "restricted" + RESTRICTED: Final = "restricted" -class RestrictedJoinRuleTypes(StrEnum): +class RestrictedJoinRuleTypes: """Understood types for the allow rules in restricted join rules.""" - ROOM_MEMBERSHIP = "m.room_membership" + ROOM_MEMBERSHIP: Final = "m.room_membership" -class LoginType(StrEnum): - PASSWORD = "m.login.password" - EMAIL_IDENTITY = "m.login.email.identity" - MSISDN = "m.login.msisdn" - RECAPTCHA = "m.login.recaptcha" - TERMS = "m.login.terms" - SSO = "m.login.sso" - DUMMY = "m.login.dummy" - REGISTRATION_TOKEN = "org.matrix.msc3231.login.registration_token" +class LoginType: + PASSWORD: Final = "m.login.password" + EMAIL_IDENTITY: Final = "m.login.email.identity" + MSISDN: Final = "m.login.msisdn" + RECAPTCHA: Final = "m.login.recaptcha" + TERMS: Final = "m.login.terms" + SSO: Final = "m.login.sso" + DUMMY: Final = "m.login.dummy" + REGISTRATION_TOKEN: Final = "org.matrix.msc3231.login.registration_token" # This is used in the `type` parameter for /register when called by @@ -89,168 +89,169 @@ class LoginType(StrEnum): APP_SERVICE_REGISTRATION_TYPE: Final = "m.login.application_service" -class EventTypes(StrEnum): - Member = "m.room.member" - Create = "m.room.create" - Tombstone = "m.room.tombstone" - JoinRules = "m.room.join_rules" - PowerLevels = "m.room.power_levels" - Aliases = "m.room.aliases" - Redaction = "m.room.redaction" - ThirdPartyInvite = "m.room.third_party_invite" - RelatedGroups = "m.room.related_groups" - - RoomHistoryVisibility = "m.room.history_visibility" - CanonicalAlias = "m.room.canonical_alias" - Encrypted = "m.room.encrypted" - RoomAvatar = "m.room.avatar" - RoomEncryption = "m.room.encryption" - GuestAccess = "m.room.guest_access" +class EventTypes: + Member: Final = "m.room.member" + Create: Final = "m.room.create" + Tombstone: Final = "m.room.tombstone" + JoinRules: Final = "m.room.join_rules" + PowerLevels: Final = "m.room.power_levels" + Aliases: Final = "m.room.aliases" + Redaction: Final = "m.room.redaction" + ThirdPartyInvite: Final = "m.room.third_party_invite" + RelatedGroups: Final = "m.room.related_groups" + + RoomHistoryVisibility: Final = "m.room.history_visibility" + CanonicalAlias: Final = "m.room.canonical_alias" + Encrypted: Final = "m.room.encrypted" + RoomAvatar: Final = "m.room.avatar" + RoomEncryption: Final = "m.room.encryption" + GuestAccess: Final = "m.room.guest_access" # These are used for validation - Message = "m.room.message" - Topic = "m.room.topic" - Name = "m.room.name" + Message: Final = "m.room.message" + Topic: Final = "m.room.topic" + Name: Final = "m.room.name" - ServerACL = "m.room.server_acl" - Pinned = "m.room.pinned_events" + ServerACL: Final = "m.room.server_acl" + Pinned: Final = "m.room.pinned_events" - Retention = "m.room.retention" + Retention: Final = "m.room.retention" - Dummy = "org.matrix.dummy_event" + Dummy: Final = "org.matrix.dummy_event" - SpaceChild = "m.space.child" - SpaceParent = "m.space.parent" + SpaceChild: Final = "m.space.child" + SpaceParent: Final = "m.space.parent" - MSC2716_INSERTION = "org.matrix.msc2716.insertion" - MSC2716_BATCH = "org.matrix.msc2716.batch" - MSC2716_MARKER = "org.matrix.msc2716.marker" + MSC2716_INSERTION: Final = "org.matrix.msc2716.insertion" + MSC2716_BATCH: Final = "org.matrix.msc2716.batch" + MSC2716_MARKER: Final = "org.matrix.msc2716.marker" -class ToDeviceEventTypes(StrEnum): - RoomKeyRequest = "m.room_key_request" +class ToDeviceEventTypes: + RoomKeyRequest: Final = "m.room_key_request" -class DeviceKeyAlgorithms(StrEnum): +class DeviceKeyAlgorithms: """Spec'd algorithms for the generation of per-device keys""" - ED25519 = "ed25519" - CURVE25519 = "curve25519" - SIGNED_CURVE25519 = "signed_curve25519" + ED25519: Final = "ed25519" + CURVE25519: Final = "curve25519" + SIGNED_CURVE25519: Final = "signed_curve25519" -class EduTypes(StrEnum): - Presence = "m.presence" +class EduTypes: + Presence: Final = "m.presence" -class RejectedReason(StrEnum): - AUTH_ERROR = "auth_error" +class RejectedReason: + AUTH_ERROR: Final = "auth_error" -class RoomCreationPreset(StrEnum): - PRIVATE_CHAT = "private_chat" - PUBLIC_CHAT = "public_chat" - TRUSTED_PRIVATE_CHAT = "trusted_private_chat" +class RoomCreationPreset: + PRIVATE_CHAT: Final = "private_chat" + PUBLIC_CHAT: Final = "public_chat" + TRUSTED_PRIVATE_CHAT: Final = "trusted_private_chat" -class ThirdPartyEntityKind(StrEnum): - USER = "user" - LOCATION = "location" +class ThirdPartyEntityKind: + USER: Final = "user" + LOCATION: Final = "location" ServerNoticeMsgType: Final = "m.server_notice" ServerNoticeLimitReached: Final = "m.server_notice.usage_limit_reached" -class UserTypes(StrEnum): +class UserTypes: """Allows for user type specific behaviour. With the benefit of hindsight 'admin' and 'guest' users should also be UserTypes. Normal users are type None """ - SUPPORT = "support" - BOT = "bot" + SUPPORT: Final = "support" + BOT: Final = "bot" + ALL_USER_TYPES: Final = (SUPPORT, BOT) -class RelationTypes(StrEnum): +class RelationTypes: """The types of relations known to this server.""" - ANNOTATION = "m.annotation" - REPLACE = "m.replace" - REFERENCE = "m.reference" - THREAD = "io.element.thread" + ANNOTATION: Final = "m.annotation" + REPLACE: Final = "m.replace" + REFERENCE: Final = "m.reference" + THREAD: Final = "io.element.thread" -class LimitBlockingTypes(StrEnum): +class LimitBlockingTypes: """Reasons that a server may be blocked""" - MONTHLY_ACTIVE_USER = "monthly_active_user" - HS_DISABLED = "hs_disabled" + MONTHLY_ACTIVE_USER: Final = "monthly_active_user" + HS_DISABLED: Final = "hs_disabled" -class EventContentFields(StrEnum): +class EventContentFields: """Fields found in events' content, regardless of type.""" # Labels for the event, cf https://github.com/matrix-org/matrix-doc/pull/2326 - LABELS = "org.matrix.labels" + LABELS: Final = "org.matrix.labels" # Timestamp to delete the event after # cf https://github.com/matrix-org/matrix-doc/pull/2228 - SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after" + SELF_DESTRUCT_AFTER: Final = "org.matrix.self_destruct_after" # cf https://github.com/matrix-org/matrix-doc/pull/1772 - ROOM_TYPE = "type" + ROOM_TYPE: Final = "type" # Whether a room can federate. - FEDERATE = "m.federate" + FEDERATE: Final = "m.federate" # The creator of the room, as used in `m.room.create` events. - ROOM_CREATOR = "creator" + ROOM_CREATOR: Final = "creator" # Used in m.room.guest_access events. - GUEST_ACCESS = "guest_access" + GUEST_ACCESS: Final = "guest_access" # Used on normal messages to indicate they were historically imported after the fact - MSC2716_HISTORICAL = "org.matrix.msc2716.historical" + MSC2716_HISTORICAL: Final = "org.matrix.msc2716.historical" # For "insertion" events to indicate what the next batch ID should be in # order to connect to it - MSC2716_NEXT_BATCH_ID = "org.matrix.msc2716.next_batch_id" + MSC2716_NEXT_BATCH_ID: Final = "org.matrix.msc2716.next_batch_id" # Used on "batch" events to indicate which insertion event it connects to - MSC2716_BATCH_ID = "org.matrix.msc2716.batch_id" + MSC2716_BATCH_ID: Final = "org.matrix.msc2716.batch_id" # For "marker" events - MSC2716_MARKER_INSERTION = "org.matrix.msc2716.marker.insertion" + MSC2716_MARKER_INSERTION: Final = "org.matrix.msc2716.marker.insertion" # The authorising user for joining a restricted room. - AUTHORISING_USER = "join_authorised_via_users_server" + AUTHORISING_USER: Final = "join_authorised_via_users_server" -class RoomTypes(StrEnum): +class RoomTypes: """Understood values of the room_type field of m.room.create events.""" - SPACE = "m.space" + SPACE: Final = "m.space" -class RoomEncryptionAlgorithms(StrEnum): - MEGOLM_V1_AES_SHA2 = "m.megolm.v1.aes-sha2" - DEFAULT = MEGOLM_V1_AES_SHA2 +class RoomEncryptionAlgorithms: + MEGOLM_V1_AES_SHA2: Final = "m.megolm.v1.aes-sha2" + DEFAULT: Final = MEGOLM_V1_AES_SHA2 -class AccountDataTypes(StrEnum): - DIRECT = "m.direct" - IGNORED_USER_LIST = "m.ignored_user_list" +class AccountDataTypes: + DIRECT: Final = "m.direct" + IGNORED_USER_LIST: Final = "m.ignored_user_list" -class HistoryVisibility(StrEnum): - INVITED = "invited" - JOINED = "joined" - SHARED = "shared" - WORLD_READABLE = "world_readable" +class HistoryVisibility: + INVITED: Final = "invited" + JOINED: Final = "joined" + SHARED: Final = "shared" + WORLD_READABLE: Final = "world_readable" -class GuestAccess(StrEnum): - CAN_JOIN = "can_join" +class GuestAccess: + CAN_JOIN: Final = "can_join" # anything that is not "can_join" is considered "forbidden", but for completeness: - FORBIDDEN = "forbidden" + FORBIDDEN: Final = "forbidden" -class ReadReceiptEventFields(StrEnum): - MSC2285_HIDDEN = "org.matrix.msc2285.hidden" +class ReadReceiptEventFields: + MSC2285_HIDDEN: Final = "org.matrix.msc2285.hidden" diff --git a/synapse/event_auth.py b/synapse/event_auth.py index 39fb4a364172..e88596169862 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -840,7 +840,7 @@ def get_public_keys(invite_event: EventBase) -> List[Dict[str, Any]]: def auth_types_for_event( room_version: RoomVersion, event: Union[EventBase, EventBuilder] -) -> Set[Tuple[EventTypes, str]]: +) -> Set[Tuple[str, str]]: """Given an event, return a list of (EventType, StateKey) that may be needed to auth the event. The returned list may be a superset of what would actually be required depending on the full state of the room. diff --git a/synapse/events/validator.py b/synapse/events/validator.py index f6fa96ba3cd7..cf8693496846 100644 --- a/synapse/events/validator.py +++ b/synapse/events/validator.py @@ -189,9 +189,7 @@ def validate_builder(self, event: Union[EventBase, EventBuilder]) -> None: if "membership" not in event.content: raise SynapseError(400, "Content has not membership key") - try: - Membership(event.content["membership"]) - except ValueError: + if event.content["membership"] not in Membership.LIST: raise SynapseError(400, "Invalid membership key") self._ensure_state_event(event) diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index ab734134ee02..b62e13b725cf 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -28,7 +28,6 @@ List, Mapping, Optional, - Set, Tuple, Type, Union, @@ -383,7 +382,7 @@ def get_new_session_data() -> JsonDict: async def _get_available_ui_auth_types(self, user: UserID) -> Iterable[str]: """Get a list of the authentication types this user can use""" - ui_auth_types: Set[str] = set() + ui_auth_types = set() # if the HS supports password auth, and the user has a non-null password, we # support password auth @@ -736,7 +735,7 @@ def _auth_dict_for_flows( for f in flows: public_flows.append(f) - get_params: Dict[str, Any] = { + get_params = { LoginType.RECAPTCHA: self._get_params_recaptcha, LoginType.TERMS: self._get_params_terms, } diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 4f543973f24b..3112cc88b1cc 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -686,7 +686,7 @@ async def on_make_join_request( ) raise NotFoundError("Not an active room on this server") - event_content: JsonDict = {"membership": Membership.JOIN} + event_content = {"membership": Membership.JOIN} # If the current room is using restricted join rules, additional information # may need to be included in the event content in order to efficiently diff --git a/synapse/push/bulk_push_rule_evaluator.py b/synapse/push/bulk_push_rule_evaluator.py index 86377fbb1df3..009d8e77b05b 100644 --- a/synapse/push/bulk_push_rule_evaluator.py +++ b/synapse/push/bulk_push_rule_evaluator.py @@ -24,7 +24,6 @@ from synapse.events import EventBase from synapse.events.snapshot import EventContext from synapse.state import POWER_KEY -from synapse.types import StateMap from synapse.util.async_helpers import Linearizer from synapse.util.caches import CacheMetric, register_cache from synapse.util.caches.descriptors import lru_cache @@ -171,9 +170,7 @@ async def _get_power_levels_and_sender_level( if pl_event_id: # fastpath: if there's a power level event, that's all we need, and # not having a power level event is an extreme edge case - auth_events: StateMap[EventBase] = { - POWER_KEY: await self.store.get_event(pl_event_id) - } + auth_events = {POWER_KEY: await self.store.get_event(pl_event_id)} else: auth_events_ids = self._event_auth_handler.compute_auth_events( event, prev_state_ids, for_verification=False diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 3f782258827b..23a8bf1fdbcc 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -209,11 +209,8 @@ async def on_PUT( assert_params_in_dict(external_id, ["auth_provider", "external_id"]) user_type = body.get("user_type", None) - if user_type is not None: - try: - UserTypes(user_type) - except ValueError: - raise SynapseError(400, "Invalid user type") + if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES: + raise SynapseError(400, "Invalid user type") set_admin_to = body.get("admin", False) if not isinstance(set_admin_to, bool): @@ -484,11 +481,8 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: user_type = body.get("user_type", None) displayname = body.get("displayname", None) - if user_type is not None: - try: - UserTypes(user_type) - except ValueError: - raise SynapseError(400, "Invalid user type") + if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES: + raise SynapseError(400, "Invalid user type") if "mac" not in body: raise SynapseError(400, "mac must be specified", errcode=Codes.BAD_JSON) diff --git a/synapse/rest/client/register.py b/synapse/rest/client/register.py index 9293f4f3d5f4..bf3cb3414677 100644 --- a/synapse/rest/client/register.py +++ b/synapse/rest/client/register.py @@ -876,7 +876,7 @@ def _calculate_registration_flows( "validation is not configured" ) - flows: List[List[str]] = [] + flows = [] # only support 3PIDless registration if no 3PIDs are required if not require_email and not require_msisdn: diff --git a/synapse/state/v1.py b/synapse/state/v1.py index bfa676d1ac96..6edadea550d2 100644 --- a/synapse/state/v1.py +++ b/synapse/state/v1.py @@ -204,7 +204,7 @@ def _create_auth_events_from_maps( Returns: A map from state key to event id. """ - auth_events: MutableStateMap[str] = {} + auth_events = {} for event_ids in conflicted_state.values(): for event_id in event_ids: if event_id in state_map: @@ -269,7 +269,7 @@ def _resolve_state_events( 3. memberships 4. other events. """ - resolved_state: MutableStateMap[EventBase] = {} + resolved_state = {} if POWER_KEY in conflicted_state: events = conflicted_state[POWER_KEY] logger.debug("Resolving conflicted power levels %r", events) @@ -316,7 +316,7 @@ def _resolve_auth_events( for key in event_auth.auth_types_for_event(room_version, event) } - new_auth_events: MutableStateMap[EventBase] = {} + new_auth_events = {} for key in auth_keys: auth_event = auth_events.get(key, None) if auth_event: diff --git a/synapse/util/enum.py b/synapse/util/enum.py deleted file mode 100644 index c7a7d1843729..000000000000 --- a/synapse/util/enum.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2021 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 enum import Enum - - -class StrEnum(str, Enum): - """Enum where members are also (and must be) strings - - Similar to `IntEnum` except for strings. - Comparison and JSON serialization work as expected. - Interchangeable with regular strings when used as dictionary keys. - """ - - __str__ = str.__str__ - __format__ = str.__format__ From 856656a8eeb59afc0e682f2f2f8a526e55a692e0 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Thu, 18 Nov 2021 20:01:36 +0000 Subject: [PATCH 13/15] Leave rooms in a deterministic order --- synapse/rest/admin/space.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/synapse/rest/admin/space.py b/synapse/rest/admin/space.py index eab174d90da8..c85aec2cc528 100644 --- a/synapse/rest/admin/space.py +++ b/synapse/rest/admin/space.py @@ -88,13 +88,15 @@ async def on_DELETE( descendants, inaccessible_room_ids, ) = await self._space_hierarchy_handler.get_space_descendants(space_id) - space_room_ids = {room_id for room_id, _ in descendants} # Determine which rooms to leave by checking join rules - rooms_to_check = space_room_ids.intersection(user_room_ids) - rooms_to_leave = set() + rooms_to_leave: List[str] = [] state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) - for room_id in rooms_to_check: + for room_id, _via in descendants: + if room_id not in user_room_ids: + # The user is not in this room. There is nothing to do here. + continue + current_state_ids = await self._store.get_filtered_current_state_ids( room_id, state_filter ) @@ -108,12 +110,10 @@ async def on_DELETE( # If it turns out that the room is actually public, then we've not # actually prevented the user from joining it. join_rules = None - if join_rules != JoinRules.PUBLIC: - rooms_to_leave.add(room_id) - # Always leave the space, even if it is public - if space_id in user_room_ids: - rooms_to_leave.add(space_id) + # Leave the room if it is not public, or it is the root space. + if join_rules != JoinRules.PUBLIC or room_id == space_id: + rooms_to_leave.append(room_id) # Now start leaving rooms failures: Dict[str, List[str]] = { From 94586c596fe65850c686abb1faee3d9c6d4db3d6 Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 19 Nov 2021 15:25:43 +0000 Subject: [PATCH 14/15] Add flag to control whether remote spaces are processed --- docs/usage/administration/admin_api/spaces.md | 43 +++++++++------ synapse/handlers/space_hierarchy.py | 33 ++++++++--- synapse/rest/admin/space.py | 50 ++++++++++++----- tests/rest/admin/test_space.py | 55 ++++++++++++++----- 4 files changed, 127 insertions(+), 54 deletions(-) diff --git a/docs/usage/administration/admin_api/spaces.md b/docs/usage/administration/admin_api/spaces.md index ac917494075b..98fa5765431c 100644 --- a/docs/usage/administration/admin_api/spaces.md +++ b/docs/usage/administration/admin_api/spaces.md @@ -16,31 +16,42 @@ The API is: DELETE /_synapse/admin/v1/rooms//hierarchy/members/ ``` +with an optional body of: + +```json +{ + "include_remote_spaces": true, +} +``` + +`include_remote_spaces` controls whether to process subspaces that the +local homeserver is not participating in. The listings of such subspaces +have to be retrieved over federation and their accuracy cannot be +guaranteed. + Returning: ```json { - "left": ["!room1:example.net", "!room2:example.net", ...], - "failed": { - "!room3:example.net": [ - "Could not explore space or room fully." - ], - "!room4:example.net": [ - "Failed to leave room." - ], + "left_rooms": ["!room1:example.net", "!room2:example.net", ...], + "inaccessible_rooms": ["!subspace1:example.net", ...], + "failed_rooms": { + "!room4:example.net": "Failed to leave room.", ... } } ``` -`left`: A list of rooms that the user has been made to leave. +`left_rooms`: A list of rooms that the user has been made to leave. -`failed`: A dictionary with entries for rooms that could not be fully -processed. The values of the dictionary are lists of failure reasons. -Rooms may appear here if: - * The user failed to leave them for any reason. - * The room is a space that the local homeserver is not in, and so - its full list of child rooms could not be determined. +`inaccessible_rooms`: A list of rooms and spaces that the local +homeserver is not in, and may have not been fully processed. Rooms may +appear here if: + * The room is a space that the local homeserver is not in, and so its + full list of child rooms could not be determined. * The room is inaccessible to the local homeserver, and it is not known whether the room is a subspace containing further rooms. - * Some combination of the above. + +`failed_rooms`: A dictionary of errors encountered when leaving rooms. +The keys of the dictionary are room IDs and the values of the dictionary +are error messages. diff --git a/synapse/handlers/space_hierarchy.py b/synapse/handlers/space_hierarchy.py index 619059b55efa..53fc29051e03 100644 --- a/synapse/handlers/space_hierarchy.py +++ b/synapse/handlers/space_hierarchy.py @@ -50,12 +50,18 @@ def __init__(self, hs: "HomeServer"): self._server_name = hs.hostname async def get_space_descendants( - self, space_id: str, via: Optional[Iterable[str]] = None + self, + space_id: str, + via: Optional[Iterable[str]] = None, + enable_federation: Optional[bool] = True, ) -> Tuple[Sequence[Tuple[str, Iterable[str]]], Sequence[str]]: """Gets the children of a space, recursively. Args: space_id: The room ID of the space. + via: A list of servers which may know about the space. + enable_federation: A boolean controlling whether children of unknown rooms + should be fetched over federation. Defaults to `True`. Returns: A tuple containing: @@ -65,6 +71,8 @@ async def get_space_descendants( Rooms in this list are either spaces not known locally, and thus require listing over federation, or are unknown rooms or subspaces completely inaccessible to the local homeserver which may contain further rooms. + Subspaces requiring listing over federation are always included here, + regardless of the value of the `enable_federation` flag. This list is a subset of the previous list, except it may include `space_id`. @@ -91,13 +99,21 @@ async def get_space_descendants( children, federation_room_chunks, ) = await self._get_space_children( - space_id, via, federation_room_chunks + space_id, + via, + federation_room_chunks, + enable_federation=enable_federation, ) except SynapseError: # Could not list children over federation inaccessible_room_ids.append(space_id) continue + # Children were retrieved over federation, which is not guaranteed to be + # the full list. + if not is_in_room: + inaccessible_room_ids.append(space_id) + for child_room_id, child_via in reversed(children): if child_room_id in seen: continue @@ -109,11 +125,6 @@ async def get_space_descendants( # `_get_space_children`. todo.append((child_room_id, child_via, federation_room_chunks)) - # Children were retrieved over federation, which is not guaranteed to be - # the full list. - if not is_in_room: - inaccessible_room_ids.append(space_id) - return descendants, inaccessible_room_ids async def _get_space_children( @@ -121,6 +132,7 @@ async def _get_space_children( space_id: str, via: Optional[Iterable[str]] = None, federation_room_chunks: Optional[Mapping[str, Optional[JsonDict]]] = None, + enable_federation: Optional[bool] = True, ) -> Tuple[ bool, Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] ]: @@ -152,7 +164,7 @@ async def _get_space_children( Raises: SynapseError: if `space_id` is not known locally and its children could not - be retrieved over federation. + be retrieved over federation or `enable_federation` is `False`. """ via = via or [] federation_room_chunks = federation_room_chunks or {} @@ -178,6 +190,11 @@ async def _get_space_children( # `space_id` is not a space according to federation. return False, [], {} + if not enable_federation: + raise SynapseError( + 502, f"{space_id} is not accessible to the local homeserver" + ) + children, room_chunks = await self._get_space_children_remote(space_id, via) return False, children, room_chunks diff --git a/synapse/rest/admin/space.py b/synapse/rest/admin/space.py index c85aec2cc528..1a152851d54b 100644 --- a/synapse/rest/admin/space.py +++ b/synapse/rest/admin/space.py @@ -12,11 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. import logging +from http import HTTPStatus from typing import TYPE_CHECKING, Dict, List, Tuple from synapse.api.constants import EventTypes, JoinRules, Membership from synapse.api.errors import SynapseError -from synapse.http.servlet import ResolveRoomIdMixin, RestServlet +from synapse.http.servlet import ( + ResolveRoomIdMixin, + RestServlet, + parse_json_object_from_request, +) from synapse.http.site import SynapseRequest from synapse.rest.admin._base import admin_patterns, assert_user_is_admin from synapse.storage.state import StateFilter @@ -56,26 +61,38 @@ async def on_DELETE( Returns: A tuple containing the HTTP status code and a JSON dictionary containing: - * `left`: A list of rooms that the user has been made to leave. - * `failed`: A with entries for rooms that could not be fully processed. - The values of the dictionary are lists of failure reasons. - Rooms may appear here if: - * The user failed to leave them for any reason. + * `left_rooms`: A list of rooms that the user has been made to leave. + * `inaccessible_rooms`: A list of rooms and spaces that the local + homeserver is not in, and may have not been fully processed. Rooms may + appear here if: * The room is a space that the local homeserver is not in, and so its full list of child rooms could not be determined. * The room is inaccessible to the local homeserver, and it is not known whether the room is a subspace containing further rooms. - * Some combination of the above. + * `failed_rooms`: A dictionary of errors encountered when leaving rooms. + The keys of the dictionary are room IDs and the values of the dictionary + are error messages. """ requester = await self._auth.get_user_by_req(request) await assert_user_is_admin(self._auth, requester.user) + content = parse_json_object_from_request(request, allow_empty_body=True) + include_remote_spaces = content.get("include_remote_spaces", True) + if not isinstance(include_remote_spaces, bool): + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "'include_remote_spaces' parameter must be a boolean", + ) + space_id, _ = await self.resolve_room_id(space_id) target_user = UserID.from_string(user_id) if not self._hs.is_mine(target_user): - raise SynapseError(400, "This endpoint can only be used with local users") + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "This endpoint can only be used with local users", + ) # Fetch the list of rooms the target user is currently in user_rooms = await self._store.get_rooms_for_local_user_where_membership_is( @@ -87,7 +104,9 @@ async def on_DELETE( ( descendants, inaccessible_room_ids, - ) = await self._space_hierarchy_handler.get_space_descendants(space_id) + ) = await self._space_hierarchy_handler.get_space_descendants( + space_id, enable_federation=include_remote_spaces + ) # Determine which rooms to leave by checking join rules rooms_to_leave: List[str] = [] @@ -116,10 +135,7 @@ async def on_DELETE( rooms_to_leave.append(room_id) # Now start leaving rooms - failures: Dict[str, List[str]] = { - room_id: ["Could not fully explore space or room."] - for room_id in inaccessible_room_ids - } + failures: Dict[str, str] = {} left_rooms: List[str] = [] fake_requester = create_requester( @@ -144,6 +160,10 @@ async def on_DELETE( ) left_rooms.append(room_id) except Exception as e: - failures.get(room_id, []).append(str(e)) + failures[room_id] = str(e) - return 200, {"left": left_rooms, "failed": failures} + return 200, { + "left_rooms": left_rooms, + "inaccessible_rooms": inaccessible_room_ids, + "failed_rooms": failures, + } diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py index 46267b0b899b..70d6776258e5 100644 --- a/tests/rest/admin/test_space.py +++ b/tests/rest/admin/test_space.py @@ -164,9 +164,14 @@ def test_public_space(self) -> None: ) response = self._remove_from_space(self.target_user) - - self.assertCountEqual(response["left"], [self.space_id]) - self.assertEqual(response["failed"], {}) + self.assertEqual( + response, + { + "left_rooms": [self.space_id], + "inaccessible_rooms": [], + "failed_rooms": {}, + }, + ) membership, _ = self.get_success( self.store.get_local_current_membership_for_user_in_room( @@ -183,9 +188,14 @@ def test_public_room(self) -> None: self.helper.join(public_room_id, self.target_user, tok=self.target_user_tok) response = self._remove_from_space(self.target_user) - - self.assertCountEqual(response["left"], [self.space_id]) - self.assertEqual(response["failed"], {}) + self.assertEqual( + response, + { + "left_rooms": [self.space_id], + "inaccessible_rooms": [], + "failed_rooms": {}, + }, + ) membership, _ = self.get_success( self.store.get_local_current_membership_for_user_in_room( @@ -207,9 +217,14 @@ def test_invited(self) -> None: ) response = self._remove_from_space(self.target_user) - - self.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) - self.assertEqual(response["failed"], {}) + self.assertEqual( + response, + { + "left_rooms": [self.space_id, invite_only_room_id], + "inaccessible_rooms": [], + "failed_rooms": {}, + }, + ) membership, _ = self.get_success( self.store.get_local_current_membership_for_user_in_room( @@ -234,9 +249,14 @@ def test_invite_only_room(self) -> None: ) response = self._remove_from_space(self.target_user) - - self.assertCountEqual(response["left"], [self.space_id, invite_only_room_id]) - self.assertEqual(response["failed"], {}) + self.assertEqual( + response, + { + "left_rooms": [self.space_id, invite_only_room_id], + "inaccessible_rooms": [], + "failed_rooms": {}, + }, + ) membership, _ = self.get_success( self.store.get_local_current_membership_for_user_in_room( @@ -252,9 +272,14 @@ def test_restricted_room(self) -> None: self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok) response = self._remove_from_space(self.target_user) - - self.assertCountEqual(response["left"], [self.space_id, restricted_room_id]) - self.assertEqual(response["failed"], {}) + self.assertEqual( + response, + { + "left_rooms": [self.space_id, restricted_room_id], + "inaccessible_rooms": [], + "failed_rooms": {}, + }, + ) membership, _ = self.get_success( self.store.get_local_current_membership_for_user_in_room( From 64c56177a4dcd7e63939f0e307b433627fa7ccbc Mon Sep 17 00:00:00 2001 From: Sean Quah Date: Fri, 19 Nov 2021 16:31:43 +0000 Subject: [PATCH 15/15] Add tests for remote spaces --- tests/handlers/test_space_hierarchy.py | 64 +++++++++++++- tests/rest/admin/test_space.py | 118 ++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/tests/handlers/test_space_hierarchy.py b/tests/handlers/test_space_hierarchy.py index 548173d1dbf4..63bc93d558c3 100644 --- a/tests/handlers/test_space_hierarchy.py +++ b/tests/handlers/test_space_hierarchy.py @@ -12,11 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional +from typing import Dict, Iterable, Mapping, NoReturn, Optional, Sequence, Tuple +from unittest import mock from twisted.test.proto_helpers import MemoryReactor from synapse.api.constants import EventContentFields, EventTypes, RoomTypes +from synapse.handlers.space_hierarchy import SpaceHierarchyHandler from synapse.rest import admin from synapse.rest.client import login, room from synapse.server import HomeServer @@ -120,6 +122,66 @@ def test_invalid_room(self): self.assertEqual(descendants, [(space_id, []), (room_id, [self.hs.hostname])]) self.assertEqual(inaccessible_room_ids, [room_id]) + def test_remote_space_with_federation_enabled(self): + """Tests iteration over a remote space with federation enabled.""" + space_id = "!space:remote" + room_id = "!room:remote" + + async def _get_space_children_remote( + _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str] + ) -> Tuple[ + Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] + ]: + if space_id == "!space:remote": + self.assertEqual(via, ["remote"]) + return [("!room:remote", ["remote"])], {} + elif space_id == "!room:remote": + self.assertEqual(via, ["remote"]) + return [], {} + else: + self.fail( + f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call" + ) + raise # `fail` is missing type hints + + with mock.patch( + "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote", + new=_get_space_children_remote, + ): + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_space_descendants( + space_id, via=["remote"], enable_federation=True + ) + ) + + self.assertEqual(descendants, [(space_id, ["remote"]), (room_id, ["remote"])]) + self.assertEqual(inaccessible_room_ids, [space_id, room_id]) + + def test_remote_space_with_federation_disabled(self): + """Tests iteration over a remote space with federation disabled.""" + space_id = "!space:remote" + + async def _get_space_children_remote( + _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str] + ) -> NoReturn: + self.fail( + f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call" + ) + raise # `fail` is missing type hints + + with mock.patch( + "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote", + new=_get_space_children_remote, + ): + descendants, inaccessible_room_ids = self.get_success( + self.handler.get_space_descendants( + space_id, via=["remote"], enable_federation=False + ) + ) + + self.assertEqual(descendants, [(space_id, ["remote"])]) + self.assertEqual(inaccessible_room_ids, [space_id]) + def test_cycle(self): """Tests iteration over a cyclic space.""" # space_id diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py index 70d6776258e5..2aa5a2614290 100644 --- a/tests/rest/admin/test_space.py +++ b/tests/rest/admin/test_space.py @@ -12,7 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional, Tuple, Union +from typing import ( + Dict, + Iterable, + List, + Mapping, + NoReturn, + Optional, + Sequence, + Tuple, + Union, +) +from unittest import mock from typing_extensions import Literal @@ -28,6 +39,7 @@ RoomTypes, ) from synapse.api.room_versions import RoomVersions +from synapse.handlers.space_hierarchy import SpaceHierarchyHandler from synapse.rest.client import login, room from synapse.server import HomeServer from synapse.types import JsonDict @@ -73,12 +85,17 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer): self.subspace_id = self._create_space((JoinRules.RESTRICTED, self.space_id)) self._add_child(self.space_id, self.subspace_id) - def _add_child(self, space_id: str, room_id: str) -> None: + def _add_child( + self, space_id: str, room_id: str, via: Optional[List[str]] = None + ) -> None: """Adds a room to a space.""" + if via is None: + via = [self.hs.hostname] + self.helper.send_state( space_id, event_type=EventTypes.SpaceChild, - body={"via": [self.hs.hostname]}, + body={"via": via}, tok=self.space_owner_user_tok, state_key=room_id, ) @@ -141,13 +158,26 @@ def _create_room( return room_id - def _remove_from_space(self, user_id: str) -> JsonDict: + def _remove_from_space( + self, + user_id: str, + space_id: Optional[str] = None, + include_remote_spaces: Optional[bool] = None, + ) -> JsonDict: """Removes the given user from the test space.""" + if space_id is None: + space_id = self.space_id + + content: Union[bytes, JsonDict] = b"" + if include_remote_spaces is not None: + content = {"include_remote_spaces": include_remote_spaces} + url = f"/_synapse/admin/v1/rooms/{self.space_id}/hierarchy/members/{user_id}" channel = self.make_request( "DELETE", url.encode("ascii"), access_token=self.admin_user_tok, + content=content, ) self.assertEqual(200, channel.code, channel.json_body) @@ -287,3 +317,83 @@ def test_restricted_room(self) -> None: ) ) self.assertEqual(membership, Membership.LEAVE) + + def test_remote_space(self) -> None: + """Tests that the user is made to leave rooms in a remote space.""" + remote_space_id = "!space:remote" + self._add_child(self.subspace_id, remote_space_id, via=["remote"]) + + restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id)) + self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok) + + async def _get_space_children_remote( + _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str] + ) -> Tuple[ + Sequence[Tuple[str, Iterable[str]]], Mapping[str, Optional[JsonDict]] + ]: + self.assertEqual(space_id, remote_space_id) + self.assertEqual(via, ["remote"]) + + return [(restricted_room_id, [self.hs.hostname])], {} + + with mock.patch( + "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote", + new=_get_space_children_remote, + ): + response = self._remove_from_space( + self.target_user, space_id="!space:remote", include_remote_spaces=True + ) + self.assertEqual( + response, + { + "left_rooms": [self.space_id, restricted_room_id], + "inaccessible_rooms": [remote_space_id], + "failed_rooms": {}, + }, + ) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, restricted_room_id + ) + ) + self.assertEqual(membership, Membership.LEAVE) + + def test_remote_spaces_excluded(self) -> None: + """Tests the exclusion of remote spaces.""" + remote_space_id = "!space:remote" + self._add_child(self.subspace_id, remote_space_id, via=["remote"]) + + restricted_room_id = self._create_room((JoinRules.RESTRICTED, self.space_id)) + self.helper.join(restricted_room_id, self.target_user, tok=self.target_user_tok) + + async def _get_space_children_remote( + _self: SpaceHierarchyHandler, space_id: str, via: Iterable[str] + ) -> NoReturn: + self.fail( + f"Unexpected _get_space_children_remote({space_id!r}, {via!r}) call" + ) + raise # `fail` is missing type hints + + with mock.patch( + "synapse.handlers.space_hierarchy.SpaceHierarchyHandler._get_space_children_remote", + new=_get_space_children_remote, + ): + response = self._remove_from_space( + self.target_user, space_id="!space:remote", include_remote_spaces=False + ) + self.assertEqual( + response, + { + "left_rooms": [self.space_id], + "inaccessible_rooms": [remote_space_id], + "failed_rooms": {}, + }, + ) + + membership, _ = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.target_user, restricted_room_id + ) + ) + self.assertEqual(membership, Membership.JOIN)