diff --git a/changelog.d/19260.feature b/changelog.d/19260.feature new file mode 100644 index 00000000000..19b192a009c --- /dev/null +++ b/changelog.d/19260.feature @@ -0,0 +1 @@ +Add `memberships` endpoint to the admin API. This is useful for forensics and T&S purpose. diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index 4de7e856420..ebcd31d0505 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -505,6 +505,39 @@ with a body of: } ``` +## List room memberships of a user + +Gets a list of room memberships for a specific `user_id`. + +The API is: + +``` +GET /_synapse/admin/v1/users//memberships +``` + +A response body like the following is returned: + +```json + { + "!DuGcnbhHGaSZQoNQR:matrix.org": "join", + "!ZtSaPCawyWtxfWiIy:matrix.org": "leave", + } +``` + +The server returns the list of memberships for rooms of which the server +are member. If the user is local, all the room memberships of the user +are returned. + +**Parameters** + +The following parameters should be set in the URL: + +- `user_id` - fully qualified: for example, `@user:server.com`. + +**Response** + +A map of `room_id` to `membership` state. + ## List joined rooms of a user Gets a list of all `room_id` that a specific `user_id` is joined to and is a member of (participating in). diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index e34ebb17e62..fe3eeafd9f9 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -114,7 +114,8 @@ UserByThreePid, UserInvitesCount, UserJoinedRoomCount, - UserMembershipRestServlet, + UserJoinedRoomsRestServlet, + UserMembershipsRestServlet, UserRegisterServlet, UserReplaceMasterCrossSigningKeyRestServlet, UserRestServletV2, @@ -297,7 +298,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: VersionServlet(hs).register(http_server) if not auth_delegated: UserAdminServlet(hs).register(http_server) - UserMembershipRestServlet(hs).register(http_server) + UserJoinedRoomsRestServlet(hs).register(http_server) + UserMembershipsRestServlet(hs).register(http_server) if not auth_delegated: UserTokenRestServlet(hs).register(http_server) UserRestServletV2(hs).register(http_server) diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 42e9f8043d6..dd853030a1a 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1031,7 +1031,7 @@ async def on_PUT( return HTTPStatus.OK, {} -class UserMembershipRestServlet(RestServlet): +class UserJoinedRoomsRestServlet(RestServlet): """ Get list of joined room ID's for a user. """ @@ -1054,6 +1054,28 @@ async def on_GET( return HTTPStatus.OK, rooms_response +class UserMembershipsRestServlet(RestServlet): + """ + Get list of left room ID's for a user. + """ + + PATTERNS = admin_patterns("/users/(?P[^/]*)/memberships$") + + def __init__(self, hs: "HomeServer"): + self.is_mine = hs.is_mine + self.auth = hs.get_auth() + self.store = hs.get_datastores().main + + async def on_GET( + self, request: SynapseRequest, user_id: str + ) -> tuple[int, JsonDict]: + await assert_requester_is_admin(self.auth, request) + + memberships = await self.store.get_memberships_for_user(user_id) + + return HTTPStatus.OK, memberships + + class PushersRestServlet(RestServlet): """ Gets information about all pushers for a specific `user_id`. diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py index 9b06ab69fed..7c06080f10a 100644 --- a/synapse/storage/databases/main/roommember.py +++ b/synapse/storage/databases/main/roommember.py @@ -747,6 +747,27 @@ async def get_rooms_user_currently_banned_from( return frozenset(room_ids) + async def get_memberships_for_user(self, user_id: str) -> dict[str, str]: + """Returns a dict of room_id to membership state for a given user. + + If a remote user only returns rooms this server is currently + participating in. + """ + + rows = cast( + list[tuple[str, str]], + await self.db_pool.simple_select_list( + "current_state_events", + keyvalues={ + "type": EventTypes.Member, + "state_key": user_id, + }, + retcols=["room_id", "membership"], + desc="get_memberships_for_user", + ), + ) + return dict(rows) + @cached(max_entries=500000, iterable=True) async def get_rooms_for_user(self, user_id: str) -> frozenset[str]: """Returns a set of room_ids the user is currently joined to. diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index 1c340efa0cd..20221f6539d 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -2976,6 +2976,86 @@ def test_join_private_room_if_owner(self) -> None: self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0]) + def test_joined_rooms(self) -> None: + """ + Test joined_rooms admin endpoint. + """ + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/join/{self.public_room_id}", + content={"user_id": self.second_user_id}, + access_token=self.second_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(self.public_room_id, channel.json_body["room_id"]) + + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/users/{self.second_user_id}/joined_rooms", + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual(self.public_room_id, channel.json_body["joined_rooms"][0]) + + def test_memberships(self) -> None: + """ + Test user memberships admin endpoint. + """ + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/join/{self.public_room_id}", + content={"user_id": self.second_user_id}, + access_token=self.second_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + other_room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/join/{other_room_id}", + content={"user_id": self.second_user_id}, + access_token=self.second_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/users/{self.second_user_id}/memberships", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual( + {self.public_room_id: Membership.JOIN, other_room_id: Membership.JOIN}, + channel.json_body, + ) + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/rooms/{other_room_id}/leave", + content={"user_id": self.second_user_id}, + access_token=self.second_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/users/{self.second_user_id}/memberships", + access_token=self.admin_user_tok, + ) + + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual( + {self.public_room_id: Membership.JOIN, other_room_id: Membership.LEAVE}, + channel.json_body, + ) + def test_context_as_non_admin(self) -> None: """ Test that, without being admin, one cannot use the context admin API