Skip to content

Commit ded0f1f

Browse files
committed
Add memberships admin API
1 parent a8e5c31 commit ded0f1f

File tree

6 files changed

+162
-3
lines changed

6 files changed

+162
-3
lines changed

changelog.d/19260.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `memberships` endpoint to the admin API. This is useful for forensics and T&S purpose.

docs/admin_api/user_admin_api.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,39 @@ with a body of:
505505
}
506506
```
507507

508+
## List room memberships of a user
509+
510+
Gets a list of room memberships for a specific `user_id`.
511+
512+
The API is:
513+
514+
```
515+
GET /_synapse/admin/v1/users/<user_id>/memberships
516+
```
517+
518+
A response body like the following is returned:
519+
520+
```json
521+
{
522+
"!DuGcnbhHGaSZQoNQR:matrix.org": "join",
523+
"!ZtSaPCawyWtxfWiIy:matrix.org": "leave",
524+
}
525+
```
526+
527+
The server returns the list of memberships for rooms of which the server
528+
are member. If the user is local, all the room memberships of the user
529+
are returned.
530+
531+
**Parameters**
532+
533+
The following parameters should be set in the URL:
534+
535+
- `user_id` - fully qualified: for example, `@user:server.com`.
536+
537+
**Response**
538+
539+
A map of `room_id` to `membership` state.
540+
508541
## List joined rooms of a user
509542

510543
Gets a list of all `room_id` that a specific `user_id` is joined to and is a member of (participating in).

synapse/rest/admin/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@
114114
UserByThreePid,
115115
UserInvitesCount,
116116
UserJoinedRoomCount,
117-
UserMembershipRestServlet,
117+
UserJoinedRoomsRestServlet,
118+
UserMembershipsRestServlet,
118119
UserRegisterServlet,
119120
UserReplaceMasterCrossSigningKeyRestServlet,
120121
UserRestServletV2,
@@ -297,7 +298,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
297298
VersionServlet(hs).register(http_server)
298299
if not auth_delegated:
299300
UserAdminServlet(hs).register(http_server)
300-
UserMembershipRestServlet(hs).register(http_server)
301+
UserJoinedRoomsRestServlet(hs).register(http_server)
302+
UserMembershipsRestServlet(hs).register(http_server)
301303
if not auth_delegated:
302304
UserTokenRestServlet(hs).register(http_server)
303305
UserRestServletV2(hs).register(http_server)

synapse/rest/admin/users.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1031,7 +1031,7 @@ async def on_PUT(
10311031
return HTTPStatus.OK, {}
10321032

10331033

1034-
class UserMembershipRestServlet(RestServlet):
1034+
class UserJoinedRoomsRestServlet(RestServlet):
10351035
"""
10361036
Get list of joined room ID's for a user.
10371037
"""
@@ -1054,6 +1054,28 @@ async def on_GET(
10541054
return HTTPStatus.OK, rooms_response
10551055

10561056

1057+
class UserMembershipsRestServlet(RestServlet):
1058+
"""
1059+
Get list of left room ID's for a user.
1060+
"""
1061+
1062+
PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/memberships$")
1063+
1064+
def __init__(self, hs: "HomeServer"):
1065+
self.is_mine = hs.is_mine
1066+
self.auth = hs.get_auth()
1067+
self.store = hs.get_datastores().main
1068+
1069+
async def on_GET(
1070+
self, request: SynapseRequest, user_id: str
1071+
) -> tuple[int, JsonDict]:
1072+
await assert_requester_is_admin(self.auth, request)
1073+
1074+
memberships = await self.store.get_memberships_for_user(user_id)
1075+
1076+
return HTTPStatus.OK, memberships
1077+
1078+
10571079
class PushersRestServlet(RestServlet):
10581080
"""
10591081
Gets information about all pushers for a specific `user_id`.

synapse/storage/databases/main/roommember.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,27 @@ async def get_rooms_user_currently_banned_from(
747747

748748
return frozenset(room_ids)
749749

750+
async def get_memberships_for_user(self, user_id: str) -> dict[str, str]:
751+
"""Returns a dict of room_id to membership state for a given user.
752+
753+
If a remote user only returns rooms this server is currently
754+
participating in.
755+
"""
756+
757+
rows = cast(
758+
list[tuple[str, str]],
759+
await self.db_pool.simple_select_list(
760+
"current_state_events",
761+
keyvalues={
762+
"type": EventTypes.Member,
763+
"state_key": user_id,
764+
},
765+
retcols=["room_id", "membership"],
766+
desc="get_memberships_for_user",
767+
),
768+
)
769+
return dict(rows)
770+
750771
@cached(max_entries=500000, iterable=True)
751772
async def get_rooms_for_user(self, user_id: str) -> frozenset[str]:
752773
"""Returns a set of room_ids the user is currently joined to.

tests/rest/admin/test_room.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2976,6 +2976,86 @@ def test_join_private_room_if_owner(self) -> None:
29762976
self.assertEqual(200, channel.code, msg=channel.json_body)
29772977
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0])
29782978

2979+
def test_joined_rooms(self) -> None:
2980+
"""
2981+
Test joined_rooms admin endpoint.
2982+
"""
2983+
2984+
channel = self.make_request(
2985+
"POST",
2986+
f"/_matrix/client/v3/join/{self.public_room_id}",
2987+
content={"user_id": self.second_user_id},
2988+
access_token=self.second_tok,
2989+
)
2990+
2991+
self.assertEqual(200, channel.code, msg=channel.json_body)
2992+
self.assertEqual(self.public_room_id, channel.json_body["room_id"])
2993+
2994+
channel = self.make_request(
2995+
"GET",
2996+
f"/_synapse/admin/v1/users/{self.second_user_id}/joined_rooms",
2997+
access_token=self.admin_user_tok,
2998+
)
2999+
self.assertEqual(200, channel.code, msg=channel.json_body)
3000+
self.assertEqual(self.public_room_id, channel.json_body["joined_rooms"][0])
3001+
3002+
def test_memberships(self) -> None:
3003+
"""
3004+
Test user memberships admin endpoint.
3005+
"""
3006+
3007+
channel = self.make_request(
3008+
"POST",
3009+
f"/_matrix/client/v3/join/{self.public_room_id}",
3010+
content={"user_id": self.second_user_id},
3011+
access_token=self.second_tok,
3012+
)
3013+
self.assertEqual(200, channel.code, msg=channel.json_body)
3014+
3015+
other_room_id = self.helper.create_room_as(
3016+
self.admin_user, tok=self.admin_user_tok
3017+
)
3018+
3019+
channel = self.make_request(
3020+
"POST",
3021+
f"/_matrix/client/v3/join/{other_room_id}",
3022+
content={"user_id": self.second_user_id},
3023+
access_token=self.second_tok,
3024+
)
3025+
self.assertEqual(200, channel.code, msg=channel.json_body)
3026+
3027+
channel = self.make_request(
3028+
"GET",
3029+
f"/_synapse/admin/v1/users/{self.second_user_id}/memberships",
3030+
access_token=self.admin_user_tok,
3031+
)
3032+
3033+
self.assertEqual(200, channel.code, msg=channel.json_body)
3034+
self.assertEqual(
3035+
{self.public_room_id: Membership.JOIN, other_room_id: Membership.JOIN},
3036+
channel.json_body,
3037+
)
3038+
3039+
channel = self.make_request(
3040+
"POST",
3041+
f"/_matrix/client/v3/rooms/{other_room_id}/leave",
3042+
content={"user_id": self.second_user_id},
3043+
access_token=self.second_tok,
3044+
)
3045+
self.assertEqual(200, channel.code, msg=channel.json_body)
3046+
3047+
channel = self.make_request(
3048+
"GET",
3049+
f"/_synapse/admin/v1/users/{self.second_user_id}/memberships",
3050+
access_token=self.admin_user_tok,
3051+
)
3052+
3053+
self.assertEqual(200, channel.code, msg=channel.json_body)
3054+
self.assertEqual(
3055+
{self.public_room_id: Membership.JOIN, other_room_id: Membership.LEAVE},
3056+
channel.json_body,
3057+
)
3058+
29793059
def test_context_as_non_admin(self) -> None:
29803060
"""
29813061
Test that, without being admin, one cannot use the context admin API

0 commit comments

Comments
 (0)