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..5645c6dd1c1 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -505,6 +505,55 @@ with a body of: } ``` +## List room memberships of a user + +Gets a list of room memberships for a specific `user_id`. This +endpoint differs from +[`GET /_synapse/admin/v1/users//joined_rooms`](#list-joined-rooms-of-a-user) +in that it returns rooms with memberships other than "join". + +The API is: + +``` +GET /_synapse/admin/v1/users//memberships +``` + +A response body like the following is returned: + +```json + { + "memberships": { + "!DuGcnbhHGaSZQoNQR:matrix.org": "join", + "!ZtSaPCawyWtxfWiIy:matrix.org": "leave", + } + } +``` + +which is a list of room membership states for the given user. This endpoint can +be used with both local and remote users, with the caveat that the homeserver will +only be aware of the memberships for rooms one of its local users has joined. + +Remote user memberships may also be out of date if all local users have since left +a room. The homeserver will thus no longer receive membership updates about it. + +The list includes rooms that the user has since left; other membership states (knock, +invite, etc.) are also possible. + +Note that rooms will only disappear from this list if they are +[purged](./rooms.md#delete-room-api) from the homeserver. + +**Parameters** + +The following parameters should be set in the URL: + +- `user_id` - fully qualified: for example, `@user:server.com`. + +**Response** + +The following fields are returned in the JSON response body: + +- `memberships` - A map of `room_id` (string) to `membership` state (string). + ## 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..406ad8f406f 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 room memberships 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": 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..ad713b4da4d 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -2976,6 +2976,120 @@ 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( + { + "memberships": { + 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) + + invited_room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + channel = self.make_request( + "POST", + f"/_matrix/client/v3/rooms/{invited_room_id}/invite", + content={"user_id": self.second_user_id}, + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + banned_room_id = self.helper.create_room_as( + self.admin_user, tok=self.admin_user_tok + ) + channel = self.make_request( + "POST", + f"/_matrix/client/v3/rooms/{banned_room_id}/ban", + content={"user_id": self.second_user_id}, + access_token=self.admin_user_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( + { + "memberships": { + self.public_room_id: Membership.JOIN, + other_room_id: Membership.LEAVE, + invited_room_id: Membership.INVITE, + banned_room_id: Membership.BAN, + } + }, + channel.json_body, + ) + def test_context_as_non_admin(self) -> None: """ Test that, without being admin, one cannot use the context admin API