summary refs log tree commit diff
diff options
context:
space:
mode:
authorSean Quah <seanq@element.io>2021-11-12 18:36:53 +0000
committerSean Quah <seanq@element.io>2021-11-16 13:45:03 +0000
commit75be1be9d5d8b70a3ae4e674da313f39d4fa8a42 (patch)
tree7801e20ed4685281ea018920d847f6d3adc4984f
parentAdd tests for `RoomHierarchyHandler` (diff)
downloadsynapse-75be1be9d5d8b70a3ae4e674da313f39d4fa8a42.tar.xz
Add admin API to remove a local user from a space
-rw-r--r--synapse/rest/admin/__init__.py2
-rw-r--r--synapse/rest/admin/room_hierarchy.py146
2 files changed, 148 insertions, 0 deletions
diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
index d78fe406c4..b7bc36c17a 100644
--- a/synapse/rest/admin/__init__.py
+++ b/synapse/rest/admin/__init__.py
@@ -45,6 +45,7 @@ from synapse.rest.admin.registration_tokens import (
     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 0000000000..16bd902b5a
--- /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<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._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}