From edb2e1869726908e9818abef1d7b62bb1bac949c Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 23 Sep 2025 12:01:05 -0700 Subject: [PATCH 1/6] add an admin API to fetch an event --- docs/admin_api/fetch_event.md | 53 ++++++++++++++++++++++++ synapse/rest/admin/__init__.py | 4 ++ synapse/rest/admin/events.py | 48 ++++++++++++++++++++++ tests/rest/admin/test_event.py | 74 ++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 docs/admin_api/fetch_event.md create mode 100644 synapse/rest/admin/events.py create mode 100644 tests/rest/admin/test_event.py diff --git a/docs/admin_api/fetch_event.md b/docs/admin_api/fetch_event.md new file mode 100644 index 00000000000..e0760fc57fc --- /dev/null +++ b/docs/admin_api/fetch_event.md @@ -0,0 +1,53 @@ +# Fetch Event API + +The fetch event API allows admins to fetch an event regardless of their membership in the room it +originated in. + +To use it, you will need to authenticate by providing an `access_token` +for a server admin: see [Admin API](../usage/administration/admin_api/). + +Request: +```http +GET /_synapse/admin/v1/fetch_event/ +``` + +The API returns a JSON body like the following: + +Response: +```json +{ + "event_id": "$hbFTSxFNaPau73B8fqGTFrkSqxOpaFjlnzOFEPw9tMA", + "event": { + "auth_events": [ + "$WhLChbYg6atHuFRP7cUd95naUtc8L0f7fqeizlsUVvc", + "$9Wj8dt02lrNEWweeq-KjRABUYKba0K9DL2liRvsAdtQ", + "$qJxBFxBt8_ODd9b3pgOL_jXP98S_igc1_kizuPSZFi4" + ], + "content": { + "body": "Hey now", + "msgtype": "m.text" + }, + "depth": 6, + "hashes": { + "sha256": "LiNw8DtrRVf55EgAH8R42Wz7WCJUqGsPt2We6qZO5Rg" + }, + "origin_server_ts": 799, + "prev_events": [ + "$cnSUrNMnC3Ywh9_W7EquFxYQjC_sT3BAAVzcUVxZq1g" + ], + "room_id": "!aIhKToCqgPTBloWMpf:test", + "sender": "@user:test", + "signatures": { + "test": { + "ed25519:a_lPym": "7mqSDwK1k7rnw34Dd8Fahu0rhPW7jPmcWPRtRDoEN9Yuv+BCM2+Rfdpv2MjxNKy3AYDEBwUwYEuaKMBaEMiKAQ" + } + }, + "type": "m.room.message", + "unsigned": { + "age_ts": 799 + } + } +} +``` + + diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index d9a6e99c5d3..0386f8a34b2 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -57,6 +57,9 @@ EventReportDetailRestServlet, EventReportsRestServlet, ) +from synapse.rest.admin.events import ( + EventRestServlet, +) from synapse.rest.admin.experimental_features import ExperimentalFeaturesRestServlet from synapse.rest.admin.federation import ( DestinationMembershipRestServlet, @@ -339,6 +342,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ExperimentalFeaturesRestServlet(hs).register(http_server) SuspendAccountRestServlet(hs).register(http_server) ScheduledTasksRestServlet(hs).register(http_server) + EventRestServlet(hs).register(http_server) def register_servlets_for_client_rest_resource( diff --git a/synapse/rest/admin/events.py b/synapse/rest/admin/events.py new file mode 100644 index 00000000000..23a3d53a6a1 --- /dev/null +++ b/synapse/rest/admin/events.py @@ -0,0 +1,48 @@ +from http import HTTPStatus +from typing import Tuple + +from synapse.api.errors import NotFoundError +from synapse.http.servlet import RestServlet +from synapse.http.site import SynapseRequest +from synapse.rest.admin import admin_patterns, assert_requester_is_admin +from synapse.server import HomeServer +from synapse.storage.databases.main.events_worker import EventRedactBehaviour +from synapse.types import JsonDict + + +class EventRestServlet(RestServlet): + """ + Get an event that is known to the homeserver. + The requester must have administrator access in Synapse. + + GET /_synapse/admin/v1/fetch_event/ + returns: + 200 OK with event json if the event is known to the homeserver. Otherwise raises + a NotFound error. + + Args: + event_id: the id of the requested event. + Returns: + JSON blob of the event + """ + + PATTERNS = admin_patterns("/fetch_event/(?P[^/]*)$") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self._store = hs.get_datastores().main + + async def on_GET( + self, request: SynapseRequest, event_id: str + ) -> Tuple[int, JsonDict]: + await assert_requester_is_admin(self._auth, request) + + event = await self._store.get_event( + event_id, EventRedactBehaviour.as_is, allow_none=True, allow_rejected=True + ) + if event is None: + raise NotFoundError("Event not found") + + res = {"event_id": event.event_id, "event": event.get_dict()} + + return HTTPStatus.OK, res diff --git a/tests/rest/admin/test_event.py b/tests/rest/admin/test_event.py new file mode 100644 index 00000000000..a749d3bfde3 --- /dev/null +++ b/tests/rest/admin/test_event.py @@ -0,0 +1,74 @@ +from twisted.internet.testing import MemoryReactor + +import synapse.rest.admin +from synapse.api.errors import Codes +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.util import Clock + +from tests import unittest + + +class FetchEventTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + room.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.admin_user = self.register_user("admin", "pass", admin=True) + self.admin_user_tok = self.login("admin", "pass") + + self.other_user = self.register_user("user", "pass") + self.other_user_tok = self.login("user", "pass") + + self.room_id1 = self.helper.create_room_as( + self.other_user, tok=self.other_user_tok, is_public=True + ) + resp = self.helper.send(self.room_id1, body="Hey now", tok=self.other_user_tok) + self.event_id = resp["event_id"] + + def test_no_auth(self) -> None: + """ + Try to get an event without authentication. + """ + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/fetch_event/{self.event_id}", + ) + + self.assertEqual(401, channel.code, msg=channel.json_body) + self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"]) + + def test_requester_is_not_admin(self) -> None: + """ + If the user is not a server admin, an error 403 is returned. + """ + + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/fetch_event/{self.event_id}", + access_token=self.other_user_tok, + ) + + self.assertEqual(403, channel.code, msg=channel.json_body) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + + def test_fetch_event(self) -> None: + """ + Test that we can successfully fetch an event + """ + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/fetch_event/{self.event_id}", + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertEqual( + channel.json_body["event"]["content"], + {"body": "Hey now", "msgtype": "m.text"}, + ) + self.assertEqual(channel.json_body["event_id"], self.event_id) + self.assertEqual(channel.json_body["event"]["type"], "m.room.message") + self.assertEqual(channel.json_body["event"]["sender"], self.other_user) From 996262f0f6daf719d20e61c7e173b1a1b27d18f0 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 23 Sep 2025 12:14:26 -0700 Subject: [PATCH 2/6] newsfragment --- changelog.d/18963.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18963.feature diff --git a/changelog.d/18963.feature b/changelog.d/18963.feature new file mode 100644 index 00000000000..2cb0d579956 --- /dev/null +++ b/changelog.d/18963.feature @@ -0,0 +1 @@ +Add an Admin API to fetch an event by ID. From 8af1c33efe4952ae93416adfdf4a0d13075d5ab7 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 23 Sep 2025 12:23:23 -0700 Subject: [PATCH 3/6] fix lint --- tests/rest/admin/test_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/admin/test_event.py b/tests/rest/admin/test_event.py index a749d3bfde3..35d437f4803 100644 --- a/tests/rest/admin/test_event.py +++ b/tests/rest/admin/test_event.py @@ -4,7 +4,7 @@ from synapse.api.errors import Codes from synapse.rest.client import login, room from synapse.server import HomeServer -from synapse.util import Clock +from synapse.util.clock import Clock from tests import unittest From c70d6f38fdce8e1c55dbbb685f863bcd07c12e49 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 23 Sep 2025 12:35:15 -0700 Subject: [PATCH 4/6] fix circular import --- synapse/rest/admin/events.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/synapse/rest/admin/events.py b/synapse/rest/admin/events.py index 23a3d53a6a1..1144d19735d 100644 --- a/synapse/rest/admin/events.py +++ b/synapse/rest/admin/events.py @@ -1,14 +1,16 @@ from http import HTTPStatus -from typing import Tuple +from typing import TYPE_CHECKING, Tuple from synapse.api.errors import NotFoundError from synapse.http.servlet import RestServlet from synapse.http.site import SynapseRequest from synapse.rest.admin import admin_patterns, assert_requester_is_admin -from synapse.server import HomeServer from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.types import JsonDict +if TYPE_CHECKING: + from synapse.server import HomeServer + class EventRestServlet(RestServlet): """ From 8536a4ca7da120aca6bb2353f93d5c4d1d620137 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 29 Sep 2025 13:58:58 -0700 Subject: [PATCH 5/6] requested changes --- docs/admin_api/fetch_event.md | 5 +++-- synapse/rest/admin/events.py | 23 ++++++++++++++++++++--- tests/rest/admin/test_event.py | 2 +- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/admin_api/fetch_event.md b/docs/admin_api/fetch_event.md index e0760fc57fc..52c72e4276d 100644 --- a/docs/admin_api/fetch_event.md +++ b/docs/admin_api/fetch_event.md @@ -1,7 +1,8 @@ # Fetch Event API The fetch event API allows admins to fetch an event regardless of their membership in the room it -originated in. +originated in. Note that this endpoint will return rejected events, and you can verify that the event +has been rejected by checking if there is a `rejection_reason` field in the `unsigned` field of the event. To use it, you will need to authenticate by providing an `access_token` for a server admin: see [Admin API](../usage/administration/admin_api/). @@ -16,7 +17,6 @@ The API returns a JSON body like the following: Response: ```json { - "event_id": "$hbFTSxFNaPau73B8fqGTFrkSqxOpaFjlnzOFEPw9tMA", "event": { "auth_events": [ "$WhLChbYg6atHuFRP7cUd95naUtc8L0f7fqeizlsUVvc", @@ -28,6 +28,7 @@ Response: "msgtype": "m.text" }, "depth": 6, + "event_id": "$hJ_kcXbVMcI82JDrbqfUJIHu61tJD86uIFJ_8hNHi7s", "hashes": { "sha256": "LiNw8DtrRVf55EgAH8R42Wz7WCJUqGsPt2We6qZO5Rg" }, diff --git a/synapse/rest/admin/events.py b/synapse/rest/admin/events.py index 1144d19735d..84e3c1cbf60 100644 --- a/synapse/rest/admin/events.py +++ b/synapse/rest/admin/events.py @@ -2,9 +2,15 @@ from typing import TYPE_CHECKING, Tuple from synapse.api.errors import NotFoundError +from synapse.events.utils import ( + SerializeEventConfig, + format_event_raw, + serialize_event, +) from synapse.http.servlet import RestServlet from synapse.http.site import SynapseRequest -from synapse.rest.admin import admin_patterns, assert_requester_is_admin +from synapse.rest.admin import admin_patterns +from synapse.rest.admin._base import assert_user_is_admin from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.types import JsonDict @@ -33,18 +39,29 @@ class EventRestServlet(RestServlet): def __init__(self, hs: "HomeServer"): self._auth = hs.get_auth() self._store = hs.get_datastores().main + self._clock = hs.get_clock() async def on_GET( self, request: SynapseRequest, event_id: str ) -> Tuple[int, JsonDict]: - await assert_requester_is_admin(self._auth, request) + requester = await self._auth.get_user_by_req(request) + await assert_user_is_admin(self._auth, requester) event = await self._store.get_event( event_id, EventRedactBehaviour.as_is, allow_none=True, allow_rejected=True ) + if event is None: raise NotFoundError("Event not found") - res = {"event_id": event.event_id, "event": event.get_dict()} + config = SerializeEventConfig( + as_client_event=False, + event_format=format_event_raw, + requester=requester, + only_event_fields=None, + include_stripped_room_state=True, + include_admin_metadata=True, + ) + res = {"event": serialize_event(event, self._clock.time_msec(), config=config)} return HTTPStatus.OK, res diff --git a/tests/rest/admin/test_event.py b/tests/rest/admin/test_event.py index 35d437f4803..4494804210d 100644 --- a/tests/rest/admin/test_event.py +++ b/tests/rest/admin/test_event.py @@ -69,6 +69,6 @@ def test_fetch_event(self) -> None: channel.json_body["event"]["content"], {"body": "Hey now", "msgtype": "m.text"}, ) - self.assertEqual(channel.json_body["event_id"], self.event_id) + self.assertEqual(channel.json_body["event"]["event_id"], self.event_id) self.assertEqual(channel.json_body["event"]["type"], "m.room.message") self.assertEqual(channel.json_body["event"]["sender"], self.other_user) From 1cbf632505056d8f410f1eae02be15f80b6e5e04 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 6 Oct 2025 11:56:15 -0700 Subject: [PATCH 6/6] don't return rejected events --- docs/admin_api/fetch_event.md | 3 +-- synapse/rest/admin/events.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/admin_api/fetch_event.md b/docs/admin_api/fetch_event.md index 52c72e4276d..baf45b8aa79 100644 --- a/docs/admin_api/fetch_event.md +++ b/docs/admin_api/fetch_event.md @@ -1,8 +1,7 @@ # Fetch Event API The fetch event API allows admins to fetch an event regardless of their membership in the room it -originated in. Note that this endpoint will return rejected events, and you can verify that the event -has been rejected by checking if there is a `rejection_reason` field in the `unsigned` field of the event. +originated in. To use it, you will need to authenticate by providing an `access_token` for a server admin: see [Admin API](../usage/administration/admin_api/). diff --git a/synapse/rest/admin/events.py b/synapse/rest/admin/events.py index 84e3c1cbf60..61b347f8f44 100644 --- a/synapse/rest/admin/events.py +++ b/synapse/rest/admin/events.py @@ -48,7 +48,9 @@ async def on_GET( await assert_user_is_admin(self._auth, requester) event = await self._store.get_event( - event_id, EventRedactBehaviour.as_is, allow_none=True, allow_rejected=True + event_id, + EventRedactBehaviour.as_is, + allow_none=True, ) if event is None: