summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/11358.feature1
-rw-r--r--docs/SUMMARY.md1
-rw-r--r--docs/usage/administration/admin_api/spaces.md57
-rw-r--r--synapse/handlers/room_summary.py6
-rw-r--r--synapse/handlers/space_hierarchy.py294
-rw-r--r--synapse/rest/admin/__init__.py2
-rw-r--r--synapse/rest/admin/space.py169
-rw-r--r--synapse/server.py5
-rw-r--r--tests/handlers/test_room_summary.py4
-rw-r--r--tests/handlers/test_space_hierarchy.py239
-rw-r--r--tests/rest/admin/test_space.py399
11 files changed, 1172 insertions, 5 deletions
diff --git a/changelog.d/11358.feature b/changelog.d/11358.feature
new file mode 100644

index 0000000000..3ed9265567 --- /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. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index b05af6d690..06d81c6663 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md
@@ -61,6 +61,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 0000000000..98fa576543 --- /dev/null +++ b/docs/usage/administration/admin_api/spaces.md
@@ -0,0 +1,57 @@ +# 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/<room_id>/hierarchy/members/<user_id> +``` + +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_rooms": ["!room1:example.net", "!room2:example.net", ...], + "inaccessible_rooms": ["!subspace1:example.net", ...], + "failed_rooms": { + "!room4:example.net": "Failed to leave room.", + ... + } +} +``` + +`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. + +`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/room_summary.py b/synapse/handlers/room_summary.py
index b2cfe537df..c06939e3ca 100644 --- a/synapse/handlers/room_summary.py +++ b/synapse/handlers/room_summary.py
@@ -1045,7 +1045,7 @@ class RoomSummaryHandler: # 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, @@ -1139,7 +1139,7 @@ class _RoomEntry: 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 @@ -1162,7 +1162,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/handlers/space_hierarchy.py b/synapse/handlers/space_hierarchy.py new file mode 100644
index 0000000000..53fc29051e --- /dev/null +++ b/synapse/handlers/space_hierarchy.py
@@ -0,0 +1,294 @@ +# 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 SpaceHierarchyHandler: + """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_space_descendants( + 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: + * 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. + 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`. + """ + via = via or [] + + # (room ID, via, federation room chunks) + 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} + + 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_space_children( + 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 + + 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_space_children`. + todo.append((child_room_id, child_via, federation_room_chunks)) + + return descendants, inaccessible_room_ids + + async def _get_space_children( + self, + 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]] + ]: + """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_space_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 or `enable_federation` is `False`. + """ + 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_space_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, [], {} + + 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 + + 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. + + 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_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. + + 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/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
index c499afd4be..79f765548b 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py
@@ -66,6 +66,7 @@ from synapse.rest.admin.rooms import ( 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 ( @@ -267,6 +268,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: RegistrationTokenRestServlet(hs).register(http_server) DestinationsRestServlet(hs).register(http_server) ListDestinationsRestServlet(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/space.py b/synapse/rest/admin/space.py new file mode 100644
index 0000000000..1a152851d5 --- /dev/null +++ b/synapse/rest/admin/space.py
@@ -0,0 +1,169 @@ +# 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 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, + 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 +from synapse.types import JsonDict, UserID, create_requester + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class RemoveSpaceMemberRestServlet(ResolveRoomIdMixin, RestServlet): + """ + Puppets a local user to remove them from all rooms in a space. + """ + + PATTERNS = admin_patterns( + "/rooms/(?P<space_id>[^/]+)/hierarchy/members/(?P<user_id>[^/]+)$" + ) + + 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._space_hierarchy_handler = hs.get_space_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_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. + * `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( + 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( + 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._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] = [] + state_filter = StateFilter.from_types([(EventTypes.JoinRules, "")]) + 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 + ) + 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 + + # 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, str] = {} + 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[room_id] = str(e) + + return 200, { + "left_rooms": left_rooms, + "inaccessible_rooms": inaccessible_room_ids, + "failed_rooms": failures, + } diff --git a/synapse/server.py b/synapse/server.py
index 185e40e4da..4ba158a3e8 100644 --- a/synapse/server.py +++ b/synapse/server.py
@@ -107,6 +107,7 @@ from synapse.handlers.room_summary import RoomSummaryHandler 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 @@ -796,6 +797,10 @@ class HomeServer(metaclass=abc.ABCMeta): return AccountDataHandler(self) @cache_in_self + def get_space_hierarchy_handler(self) -> SpaceHierarchyHandler: + return SpaceHierarchyHandler(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 e5a6a6c747..e85d112ecc 100644 --- a/tests/handlers/test_room_summary.py +++ b/tests/handlers/test_room_summary.py
@@ -28,7 +28,7 @@ from synapse.api.constants import ( 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 @@ -48,7 +48,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): diff --git a/tests/handlers/test_space_hierarchy.py b/tests/handlers/test_space_hierarchy.py new file mode 100644
index 0000000000..63bc93d558 --- /dev/null +++ b/tests/handlers/test_space_hierarchy.py
@@ -0,0 +1,239 @@ +# 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, 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 +from synapse.types import JsonDict +from synapse.util import Clock + +from tests import unittest + + +class SpaceDescendantsTestCase(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: MemoryReactor, clock: Clock, hs: HomeServer): + self.hs = hs + self.handler = self.hs.get_space_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_space_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_space_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_space_descendants(space_id) + ) + + 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 + # - 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_space_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_space_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, []) diff --git a/tests/rest/admin/test_space.py b/tests/rest/admin/test_space.py new file mode 100644
index 0000000000..2aa5a26142 --- /dev/null +++ b/tests/rest/admin/test_space.py
@@ -0,0 +1,399 @@ +# 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, + Iterable, + List, + Mapping, + NoReturn, + Optional, + Sequence, + Tuple, + Union, +) +from unittest import mock + +from typing_extensions import Literal + +from twisted.test.proto_helpers import MemoryReactor + +import synapse.rest.admin +from synapse.api.constants import ( + EventContentFields, + EventTypes, + JoinRules, + Membership, + RestrictedJoinRuleTypes, + 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 +from synapse.util import Clock + +from tests import unittest + + +class RemoveSpaceMemberTestCase(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: MemoryReactor, clock: Clock, hs: HomeServer): + 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, 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": via}, + 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, + 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) + + 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.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( + 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.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( + 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.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( + 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.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( + 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.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( + self.target_user, restricted_room_id + ) + ) + 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)