Add memberships admin API (#19260)
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
1
changelog.d/19260.feature
Normal file
1
changelog.d/19260.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add `memberships` endpoint to the admin API. This is useful for forensics and T&S purpose.
|
||||
@@ -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
|
||||
|
||||
Gets a list of all `room_id` that a specific `user_id` is joined to and is a member of (participating in).
|
||||
|
||||
@@ -114,7 +114,8 @@ from synapse.rest.admin.users import (
|
||||
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)
|
||||
|
||||
@@ -1031,7 +1031,7 @@ class UserAdminServlet(RestServlet):
|
||||
return HTTPStatus.OK, {}
|
||||
|
||||
|
||||
class UserMembershipRestServlet(RestServlet):
|
||||
class UserJoinedRoomsRestServlet(RestServlet):
|
||||
"""
|
||||
Get list of joined room ID's for a user.
|
||||
"""
|
||||
@@ -1054,6 +1054,28 @@ class UserMembershipRestServlet(RestServlet):
|
||||
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):
|
||||
"""
|
||||
Gets information about all pushers for a specific `user_id`.
|
||||
|
||||
@@ -747,6 +747,27 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore):
|
||||
|
||||
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.
|
||||
|
||||
@@ -2976,6 +2976,120 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user