Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19260.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `memberships` endpoint to the admin API. This is useful for forensics and T&S purpose.
49 changes: 49 additions & 0 deletions docs/admin_api/user_admin_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 4 additions & 2 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@
UserByThreePid,
UserInvitesCount,
UserJoinedRoomCount,
UserMembershipRestServlet,
UserJoinedRoomsRestServlet,
UserMembershipsRestServlet,
UserRegisterServlet,
UserReplaceMasterCrossSigningKeyRestServlet,
UserRestServletV2,
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion synapse/rest/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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<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`.
Expand Down
21 changes: 21 additions & 0 deletions synapse/storage/databases/main/roommember.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a cache here ? if yes we also need to add invalidation.
I didn't do it because it should be used not that often since it is only used by the admin API (for now).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anoadragon453 still not sure here ;)

"""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.
Expand Down
114 changes: 114 additions & 0 deletions tests/rest/admin/test_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading