Add memberships admin API (#19260)

Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
Mathieu Velten
2025-12-12 14:35:46 +01:00
committed by GitHub
parent 3f636386a6
commit 7347cc436e
6 changed files with 212 additions and 3 deletions

View File

@@ -0,0 +1 @@
Add `memberships` endpoint to the admin API. This is useful for forensics and T&S purpose.

View File

@@ -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/<user_id>/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/<user_id>/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 ## 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). Gets a list of all `room_id` that a specific `user_id` is joined to and is a member of (participating in).

View File

@@ -114,7 +114,8 @@ from synapse.rest.admin.users import (
UserByThreePid, UserByThreePid,
UserInvitesCount, UserInvitesCount,
UserJoinedRoomCount, UserJoinedRoomCount,
UserMembershipRestServlet, UserJoinedRoomsRestServlet,
UserMembershipsRestServlet,
UserRegisterServlet, UserRegisterServlet,
UserReplaceMasterCrossSigningKeyRestServlet, UserReplaceMasterCrossSigningKeyRestServlet,
UserRestServletV2, UserRestServletV2,
@@ -297,7 +298,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
VersionServlet(hs).register(http_server) VersionServlet(hs).register(http_server)
if not auth_delegated: if not auth_delegated:
UserAdminServlet(hs).register(http_server) UserAdminServlet(hs).register(http_server)
UserMembershipRestServlet(hs).register(http_server) UserJoinedRoomsRestServlet(hs).register(http_server)
UserMembershipsRestServlet(hs).register(http_server)
if not auth_delegated: if not auth_delegated:
UserTokenRestServlet(hs).register(http_server) UserTokenRestServlet(hs).register(http_server)
UserRestServletV2(hs).register(http_server) UserRestServletV2(hs).register(http_server)

View File

@@ -1031,7 +1031,7 @@ class UserAdminServlet(RestServlet):
return HTTPStatus.OK, {} return HTTPStatus.OK, {}
class UserMembershipRestServlet(RestServlet): class UserJoinedRoomsRestServlet(RestServlet):
""" """
Get list of joined room ID's for a user. Get list of joined room ID's for a user.
""" """
@@ -1054,6 +1054,28 @@ class UserMembershipRestServlet(RestServlet):
return HTTPStatus.OK, rooms_response return HTTPStatus.OK, rooms_response
class UserMembershipsRestServlet(RestServlet):
"""
Get list of room memberships for a user.
"""
PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/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): class PushersRestServlet(RestServlet):
""" """
Gets information about all pushers for a specific `user_id`. Gets information about all pushers for a specific `user_id`.

View File

@@ -747,6 +747,27 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore):
return frozenset(room_ids) 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) @cached(max_entries=500000, iterable=True)
async def get_rooms_for_user(self, user_id: str) -> frozenset[str]: async def get_rooms_for_user(self, user_id: str) -> frozenset[str]:
"""Returns a set of room_ids the user is currently joined to. """Returns a set of room_ids the user is currently joined to.

View File

@@ -2976,6 +2976,120 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0]) 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: def test_context_as_non_admin(self) -> None:
""" """
Test that, without being admin, one cannot use the context admin API Test that, without being admin, one cannot use the context admin API