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)
|