Skip to content

Commit f1695ac

Browse files
authored
Add an admin API to get the space hierarchy (#19021)
It is often useful when investigating a space to get information about that space and it's children. This PR adds an Admin API to return information about a space and it's children, regardless of room membership. Will not fetch information over federation about remote rooms that the server is not participating in.
1 parent 9d81bb7 commit f1695ac

File tree

6 files changed

+475
-21
lines changed

6 files changed

+475
-21
lines changed

changelog.d/19021.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add an [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html)
2+
to allow an admin to fetch the space/room hierarchy for a given space.

docs/admin_api/rooms.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,3 +1115,76 @@ Example response:
11151115
]
11161116
}
11171117
```
1118+
1119+
# Admin Space Hierarchy Endpoint
1120+
1121+
This API allows an admin to fetch the space/room hierarchy for a given space,
1122+
returning details about that room and any children the room may have, paginating
1123+
over the space tree in a depth-first manner to locate child rooms. This is
1124+
functionally similar to the [CS Hierarchy](https://spec.matrix.org/v1.16/client-server-api/#get_matrixclientv1roomsroomidhierarchy) endpoint but does not check for
1125+
room membership when returning room summaries.
1126+
1127+
The endpoint does not query other servers over federation about remote rooms
1128+
that the server has not joined. This is a deliberate trade-off: while this
1129+
means it will leave some holes in the hierarchy that we could otherwise
1130+
sometimes fill in, it significantly improves the endpoint's response time and
1131+
the admin endpoint is designed for managing rooms local to the homeserver
1132+
anyway.
1133+
1134+
**Parameters**
1135+
1136+
The following query parameters are available:
1137+
1138+
* `from` - An optional pagination token, provided when there are more rooms to
1139+
return than the limit.
1140+
* `limit` - Maximum amount of rooms to return. Must be a non-negative integer,
1141+
defaults to `50`.
1142+
* `max_depth` - The maximum depth in the tree to explore, must be a non-negative
1143+
integer. 0 would correspond to just the root room, 1 would include just the
1144+
root room's children, etc. If not provided will recurse into the space tree without limit.
1145+
1146+
Request:
1147+
1148+
```http
1149+
GET /_synapse/admin/v1/rooms/<room_id>/hierarchy
1150+
```
1151+
1152+
Response:
1153+
1154+
```json
1155+
{
1156+
"rooms":
1157+
[
1158+
{ "children_state": [
1159+
{
1160+
"content": {
1161+
"via": ["local_test_server"]
1162+
},
1163+
"origin_server_ts": 1500,
1164+
"sender": "@user:test",
1165+
"state_key": "!QrMkkqBSwYRIFNFCso:test",
1166+
"type": "m.space.child"
1167+
}
1168+
],
1169+
"name": "space room",
1170+
"guest_can_join": false,
1171+
"join_rule": "public",
1172+
"num_joined_members": 1,
1173+
"room_id": "!sPOpNyMHbZAoAOsOFL:test",
1174+
"room_type": "m.space",
1175+
"world_readable": false
1176+
},
1177+
1178+
{
1179+
"children_state": [],
1180+
"guest_can_join": true,
1181+
"join_rule": "invite",
1182+
"name": "nefarious",
1183+
"num_joined_members": 1,
1184+
"room_id": "!QrMkkqBSwYRIFNFCso:test",
1185+
"topic": "being bad",
1186+
"world_readable": false}
1187+
],
1188+
"next_batch": "KUYmRbeSpAoaAIgOKGgyaCEn"
1189+
}
1190+
```

synapse/handlers/room_summary.py

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ def __init__(self, hs: "HomeServer"):
116116
str,
117117
str,
118118
bool,
119+
bool,
120+
bool,
119121
Optional[int],
120122
Optional[int],
121123
Optional[str],
@@ -133,6 +135,8 @@ async def get_room_hierarchy(
133135
requester: Requester,
134136
requested_room_id: str,
135137
suggested_only: bool = False,
138+
omit_remote_room_hierarchy: bool = False,
139+
admin_skip_room_visibility_check: bool = False,
136140
max_depth: Optional[int] = None,
137141
limit: Optional[int] = None,
138142
from_token: Optional[str] = None,
@@ -146,6 +150,11 @@ async def get_room_hierarchy(
146150
requested_room_id: The room ID to start the hierarchy at (the "root" room).
147151
suggested_only: Whether we should only return children with the "suggested"
148152
flag set.
153+
omit_remote_room_hierarchy: Whether to skip reaching out over
154+
federation to get information on rooms which the server
155+
is not currently joined to
156+
admin_skip_room_visibility_check: Whether to skip checking if the room can
157+
be accessed by the requester, used for the admin endpoints.
149158
max_depth: The maximum depth in the tree to explore, must be a
150159
non-negative integer.
151160
@@ -173,6 +182,8 @@ async def get_room_hierarchy(
173182
requester.user.to_string(),
174183
requested_room_id,
175184
suggested_only,
185+
omit_remote_room_hierarchy,
186+
admin_skip_room_visibility_check,
176187
max_depth,
177188
limit,
178189
from_token,
@@ -182,6 +193,8 @@ async def get_room_hierarchy(
182193
requester.user.to_string(),
183194
requested_room_id,
184195
suggested_only,
196+
omit_remote_room_hierarchy,
197+
admin_skip_room_visibility_check,
185198
max_depth,
186199
limit,
187200
from_token,
@@ -193,6 +206,8 @@ async def _get_room_hierarchy(
193206
requester: str,
194207
requested_room_id: str,
195208
suggested_only: bool = False,
209+
omit_remote_room_hierarchy: bool = False,
210+
admin_skip_room_visibility_check: bool = False,
196211
max_depth: Optional[int] = None,
197212
limit: Optional[int] = None,
198213
from_token: Optional[str] = None,
@@ -204,17 +219,18 @@ async def _get_room_hierarchy(
204219
local_room = await self._store.is_host_joined(
205220
requested_room_id, self._server_name
206221
)
207-
if local_room and not await self._is_local_room_accessible(
208-
requested_room_id, requester
209-
):
210-
raise UnstableSpecAuthError(
211-
403,
212-
"User %s not in room %s, and room previews are disabled"
213-
% (requester, requested_room_id),
214-
errcode=Codes.NOT_JOINED,
215-
)
222+
if not admin_skip_room_visibility_check:
223+
if local_room and not await self._is_local_room_accessible(
224+
requested_room_id, requester
225+
):
226+
raise UnstableSpecAuthError(
227+
403,
228+
"User %s not in room %s, and room previews are disabled"
229+
% (requester, requested_room_id),
230+
errcode=Codes.NOT_JOINED,
231+
)
216232

217-
if not local_room:
233+
if not local_room and not omit_remote_room_hierarchy:
218234
room_hierarchy = await self._summarize_remote_room_hierarchy(
219235
_RoomQueueEntry(requested_room_id, remote_room_hosts or ()),
220236
False,
@@ -223,12 +239,13 @@ async def _get_room_hierarchy(
223239
if not root_room_entry or not await self._is_remote_room_accessible(
224240
requester, requested_room_id, root_room_entry.room
225241
):
226-
raise UnstableSpecAuthError(
227-
403,
228-
"User %s not in room %s, and room previews are disabled"
229-
% (requester, requested_room_id),
230-
errcode=Codes.NOT_JOINED,
231-
)
242+
if not admin_skip_room_visibility_check:
243+
raise UnstableSpecAuthError(
244+
403,
245+
"User %s not in room %s, and room previews are disabled"
246+
% (requester, requested_room_id),
247+
errcode=Codes.NOT_JOINED,
248+
)
232249

233250
# If this is continuing a previous session, pull the persisted data.
234251
if from_token:
@@ -240,13 +257,18 @@ async def _get_room_hierarchy(
240257
except StoreError:
241258
raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)
242259

243-
# If the requester, room ID, suggested-only, or max depth were modified
244-
# the session is invalid.
260+
# If the requester, room ID, suggested-only, max depth,
261+
# omit_remote_room_hierarchy, or admin_skip_room_visibility_check
262+
# were modified the session is invalid.
245263
if (
246264
requester != pagination_session["requester"]
247265
or requested_room_id != pagination_session["room_id"]
248266
or suggested_only != pagination_session["suggested_only"]
249267
or max_depth != pagination_session["max_depth"]
268+
or omit_remote_room_hierarchy
269+
!= pagination_session["omit_remote_room_hierarchy"]
270+
or admin_skip_room_visibility_check
271+
!= pagination_session["admin_skip_room_visibility_check"]
250272
):
251273
raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)
252274

@@ -301,6 +323,7 @@ async def _get_room_hierarchy(
301323
None,
302324
room_id,
303325
suggested_only,
326+
admin_skip_room_visibility_check=admin_skip_room_visibility_check,
304327
)
305328

306329
# Otherwise, attempt to use information for federation.
@@ -321,7 +344,7 @@ async def _get_room_hierarchy(
321344

322345
# If the above isn't true, attempt to fetch the room
323346
# information over federation.
324-
else:
347+
elif not omit_remote_room_hierarchy:
325348
(
326349
room_entry,
327350
children_room_entries,
@@ -378,6 +401,8 @@ async def _get_room_hierarchy(
378401
"room_id": requested_room_id,
379402
"suggested_only": suggested_only,
380403
"max_depth": max_depth,
404+
"omit_remote_room_hierarchy": omit_remote_room_hierarchy,
405+
"admin_skip_room_visibility_check": admin_skip_room_visibility_check,
381406
# The stored state.
382407
"room_queue": [
383408
attr.astuple(room_entry) for room_entry in room_queue
@@ -460,6 +485,7 @@ async def _summarize_local_room(
460485
room_id: str,
461486
suggested_only: bool,
462487
include_children: bool = True,
488+
admin_skip_room_visibility_check: bool = False,
463489
) -> Optional["_RoomEntry"]:
464490
"""
465491
Generate a room entry and a list of event entries for a given room.
@@ -476,11 +502,16 @@ async def _summarize_local_room(
476502
Otherwise, all children are returned.
477503
include_children:
478504
Whether to include the events of any children.
505+
admin_skip_room_visibility_check: Whether to skip checking if the room
506+
can be accessed by the requester, used for the admin endpoints.
479507
480508
Returns:
481509
A room entry if the room should be returned. None, otherwise.
482510
"""
483-
if not await self._is_local_room_accessible(room_id, requester, origin):
511+
if (
512+
not admin_skip_room_visibility_check
513+
and not await self._is_local_room_accessible(room_id, requester, origin)
514+
):
484515
return None
485516

486517
room_entry = await self._build_room_entry(room_id, for_federation=bool(origin))

synapse/rest/admin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
RegistrationTokenRestServlet,
7575
)
7676
from synapse.rest.admin.rooms import (
77+
AdminRoomHierarchy,
7778
BlockRoomRestServlet,
7879
DeleteRoomStatusByDeleteIdRestServlet,
7980
DeleteRoomStatusByRoomIdRestServlet,
@@ -342,6 +343,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
342343
ExperimentalFeaturesRestServlet(hs).register(http_server)
343344
SuspendAccountRestServlet(hs).register(http_server)
344345
ScheduledTasksRestServlet(hs).register(http_server)
346+
AdminRoomHierarchy(hs).register(http_server)
345347
EventRestServlet(hs).register(http_server)
346348

347349

synapse/rest/admin/rooms.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,50 @@
6363
logger = logging.getLogger(__name__)
6464

6565

66+
class AdminRoomHierarchy(RestServlet):
67+
"""
68+
Given a room, returns room details on that room and any space children of
69+
the provided room. Does not reach out over federation to fetch information about
70+
any remote rooms which the server is not currently participating in
71+
"""
72+
73+
PATTERNS = admin_patterns("/rooms/(?P<room_id>[^/]*)/hierarchy$")
74+
75+
def __init__(self, hs: "HomeServer"):
76+
self._auth = hs.get_auth()
77+
self._room_summary_handler = hs.get_room_summary_handler()
78+
self._store = hs.get_datastores().main
79+
self._storage_controllers = hs.get_storage_controllers()
80+
81+
async def on_GET(
82+
self, request: SynapseRequest, room_id: str
83+
) -> tuple[int, JsonDict]:
84+
requester = await self._auth.get_user_by_req(request)
85+
await assert_user_is_admin(self._auth, requester)
86+
87+
max_depth = parse_integer(request, "max_depth")
88+
limit = parse_integer(request, "limit")
89+
90+
room_entry_summary = await self._room_summary_handler.get_room_hierarchy(
91+
requester,
92+
room_id,
93+
# We omit details about remote rooms because we only care
94+
# about managing rooms local to the homeserver. This
95+
# also immensely helps with the response time of the
96+
# endpoint since we don't need to reach out over federation.
97+
# There is a trade-off as this will leave holes where
98+
# information about public/peekable remote rooms the
99+
# server is not participating in will be omitted.
100+
omit_remote_room_hierarchy=True,
101+
admin_skip_room_visibility_check=True,
102+
max_depth=max_depth,
103+
limit=limit,
104+
from_token=parse_string(request, "from"),
105+
)
106+
107+
return HTTPStatus.OK, room_entry_summary
108+
109+
66110
class RoomRestV2Servlet(RestServlet):
67111
"""Delete a room from server asynchronously with a background task.
68112

0 commit comments

Comments
 (0)