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
|
## 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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user