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.
33 changes: 33 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,39 @@ with a body of:
}
```

## List room memberships of a user

Gets a list of room memberships for a specific `user_id`.

The API is:

```
GET /_synapse/admin/v1/users/<user_id>/memberships
```

A response body like the following is returned:

```json
{
"!DuGcnbhHGaSZQoNQR:matrix.org": "join",
"!ZtSaPCawyWtxfWiIy:matrix.org": "leave",
}
```

The server returns the list of memberships for rooms of which the server
are member. If the user is local, all the room memberships of the user
are returned.

**Parameters**

The following parameters should be set in the URL:

- `user_id` - fully qualified: for example, `@user:server.com`.

**Response**

A map of `room_id` to `membership` state.

## 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 left room ID's 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


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).

"""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]:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we change this to use get_memberships_for_user ? it will pull quite a lot more data and this is used in a lot of places so I am tempted to not touch it to not affect performances.

Copy link
Contributor Author

@MatMaul MatMaul Dec 2, 2025

Choose a reason for hiding this comment

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

Another option would be to make get_memberships_for_user take a list of membership we are interested in, with empty meaning everything.
In this case the perf would be basically the same (with a cache on get_memberships_for_user and not on get_rooms_for_user anymore).

"""Returns a set of room_ids the user is currently joined to.
Expand Down
80 changes: 80 additions & 0 deletions tests/rest/admin/test_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -2976,6 +2976,86 @@ 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(
{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)

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(
{self.public_room_id: Membership.JOIN, other_room_id: Membership.LEAVE},
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