Skip to content
2 changes: 2 additions & 0 deletions changelog.d/19021.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add an [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) to allow an admin
to fetch the space/room hierarchy for a given space.
71 changes: 71 additions & 0 deletions docs/admin_api/rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1115,3 +1115,74 @@ Example response:
]
}
```

# Admin Space Hierarchy Endpoint

This API allows an admin to fetch the space/room hierarchy for a given space, returning details about that room and any children
the room may have, paginating over the space tree in a depth-first manner to locate child rooms. This is functionally similar to the [CS Hierarchy](https://spec.matrix.org/v1.16/client-server-api/#get_matrixclientv1roomsroomidhierarchy) endpoint but does not return information about any remote
rooms that the server is not currently joined to and does not check for room membership when returning room summaries.
Comment on lines +1122 to +1123
Copy link
Contributor

Choose a reason for hiding this comment

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

The description should be updated with the nuance that we return all known rooms but we won't go and fetch the hierarchy from federation which may leave holes. These rooms only have room_ids, etc


**Parameters**

The following query parameters are available:

* `from` - An optional pagination token, provided when there are more rooms to return than the limit.
* `limit` - Maximum amount of rooms to return. Must be a non-negative integer, defaults to `50`.
* `max_depth` - The maximum depth in the tree to explore, must be a non-negative integer. 0 would correspond to just the root room, 1 would include just the root room's children, etc. If not provided will recurse into the space tree without limit.

Request:

```http
GET /_synapse/admin/v1/rooms/<room_id>/hierarchy
```

Response:

```json
{
"rooms":
[
{"aliases": [],
"children_state": [
{
"content": {
"via": ["local_test_server"]
},
"origin_server_ts": 1500,
"sender": "@user:test",
"state_key": "!QrMkkqBSwYRIFNFCso:test",
"type": "m.space.child"
}
],
"creation_event_id": "$bVkNVtm4aDw4c0LRf_U5Ad7mZSo4WKzzQKImrk_rQcg",
"creator": "@user:test",
"guest_can_join": false,
"is_space": true,
"join_rule": "public",
"num_joined_members": 1,
"power_users": ["@user:test"],
"room_creation_ts": 1400,
"room_id": "!sPOpNyMHbZAoAOsOFL:test",
"room_type": "m.space",
"world_readable": false
},

{
"aliases": [],
"children_state": [],
"creation_event_id": "$kymNeN-gA5kzLwZ6FEQUu0_2MfeenYKINSO3dUuLYf8",
"creator": "@user:test",
"guest_can_join": true,
"is_space": false,
"join_rule": "invite",
"name": "nefarious",
"num_joined_members": 1,
"power_users": ["@user:test"],
"room_creation_ts": 999,
"room_id": "!QrMkkqBSwYRIFNFCso:test",
"topic": "being bad",
"world_readable": false}
],
"next_batch": "KUYmRbeSpAoaAIgOKGgyaCEn"
}
```
4 changes: 3 additions & 1 deletion scripts-dev/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,9 @@ def get_repo_and_check_clean_checkout(
f"{path} is not a git repository (expecting a {name} repository)."
)
while repo.is_dirty():
if not click.confirm(f"Uncommitted changes exist in {path}. Commit or stash them. Ready to continue?"):
if not click.confirm(
f"Uncommitted changes exist in {path}. Commit or stash them. Ready to continue?"
):
raise click.ClickException("Aborted.")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was necessary to prevent the lint from failing, and stopping the rest of the tests from being run.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is now fixed on develop -> #19092


return repo
Expand Down
59 changes: 45 additions & 14 deletions synapse/handlers/room_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def __init__(self, hs: "HomeServer"):
str,
str,
bool,
bool,
bool,
Optional[int],
Optional[int],
Optional[str],
Expand All @@ -133,6 +135,8 @@ async def get_room_hierarchy(
requester: Requester,
requested_room_id: str,
suggested_only: bool = False,
omit_remote_room_hierarchy: bool = False,
admin_skip_room_visibility_check: bool = False,
max_depth: Optional[int] = None,
limit: Optional[int] = None,
from_token: Optional[str] = None,
Expand All @@ -146,6 +150,10 @@ async def get_room_hierarchy(
requested_room_id: The room ID to start the hierarchy at (the "root" room).
suggested_only: Whether we should only return children with the "suggested"
flag set.
omit_remote_room_hierarchy: Whether to skip reaching out over federation to get information on rooms which the server is not
currently joined to
admin_skip_room_visibility_check: Whether to skip checking if the room can be accessed by the requester,
used for the admin endpoints.
max_depth: The maximum depth in the tree to explore, must be a
non-negative integer.

Expand Down Expand Up @@ -173,6 +181,8 @@ async def get_room_hierarchy(
requester.user.to_string(),
requested_room_id,
suggested_only,
omit_remote_room_hierarchy,
admin_skip_room_visibility_check,
max_depth,
limit,
from_token,
Expand All @@ -182,6 +192,8 @@ async def get_room_hierarchy(
requester.user.to_string(),
requested_room_id,
suggested_only,
omit_remote_room_hierarchy,
admin_skip_room_visibility_check,
max_depth,
limit,
from_token,
Expand All @@ -193,6 +205,8 @@ async def _get_room_hierarchy(
requester: str,
requested_room_id: str,
suggested_only: bool = False,
omit_remote_room_hierarchy: bool = False,
admin_skip_room_visibility_check: bool = False,
max_depth: Optional[int] = None,
limit: Optional[int] = None,
from_token: Optional[str] = None,
Expand All @@ -204,17 +218,18 @@ async def _get_room_hierarchy(
local_room = await self._store.is_host_joined(
requested_room_id, self._server_name
)
if local_room and not await self._is_local_room_accessible(
requested_room_id, requester
):
raise UnstableSpecAuthError(
403,
"User %s not in room %s, and room previews are disabled"
% (requester, requested_room_id),
errcode=Codes.NOT_JOINED,
)
if not admin_skip_room_visibility_check:
if local_room and not await self._is_local_room_accessible(
requested_room_id, requester
):
raise UnstableSpecAuthError(
403,
"User %s not in room %s, and room previews are disabled"
% (requester, requested_room_id),
errcode=Codes.NOT_JOINED,
)

if not local_room:
if not local_room and not omit_remote_room_hierarchy:
room_hierarchy = await self._summarize_remote_room_hierarchy(
_RoomQueueEntry(requested_room_id, remote_room_hosts or ()),
False,
Expand All @@ -240,13 +255,17 @@ async def _get_room_hierarchy(
except StoreError:
raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)

# If the requester, room ID, suggested-only, or max depth were modified
# the session is invalid.
# If the requester, room ID, suggested-only, max depth, omit_remote_room_hierarchy, or admin_skip_room_visibility_check
# were modified the session is invalid.
if (
requester != pagination_session["requester"]
or requested_room_id != pagination_session["room_id"]
or suggested_only != pagination_session["suggested_only"]
or max_depth != pagination_session["max_depth"]
or omit_remote_room_hierarchy
!= pagination_session["omit_remote_room_hierarchy"]
or admin_skip_room_visibility_check
!= pagination_session["admin_skip_room_visibility_check"]
):
raise SynapseError(400, "Unknown pagination token", Codes.INVALID_PARAM)

Expand Down Expand Up @@ -301,8 +320,12 @@ async def _get_room_hierarchy(
None,
room_id,
suggested_only,
admin_skip_room_visibility_check=admin_skip_room_visibility_check,
)

# if we are not fetching remote room details over federation, return what is
# known about the room
elif omit_remote_room_hierarchy:
room_entry = _RoomEntry(room_id, {"room_id": room_id}, ())
# Otherwise, attempt to use information for federation.
else:
# A previous call might have included information for this room.
Expand Down Expand Up @@ -378,6 +401,8 @@ async def _get_room_hierarchy(
"room_id": requested_room_id,
"suggested_only": suggested_only,
"max_depth": max_depth,
"omit_remote_room_hierarchy": omit_remote_room_hierarchy,
"admin_skip_room_visibility_check": admin_skip_room_visibility_check,
# The stored state.
"room_queue": [
attr.astuple(room_entry) for room_entry in room_queue
Expand Down Expand Up @@ -460,6 +485,7 @@ async def _summarize_local_room(
room_id: str,
suggested_only: bool,
include_children: bool = True,
admin_skip_room_visibility_check: bool = False,
) -> Optional["_RoomEntry"]:
"""
Generate a room entry and a list of event entries for a given room.
Expand All @@ -476,11 +502,16 @@ async def _summarize_local_room(
Otherwise, all children are returned.
include_children:
Whether to include the events of any children.
admin_skip_room_visibility_check: Whether to skip checking if the room can be accessed by the requester,
used for the admin endpoints.

Returns:
A room entry if the room should be returned. None, otherwise.
"""
if not await self._is_local_room_accessible(room_id, requester, origin):
if (
not admin_skip_room_visibility_check
and not await self._is_local_room_accessible(room_id, requester, origin)
):
return None

room_entry = await self._build_room_entry(room_id, for_federation=bool(origin))
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
RegistrationTokenRestServlet,
)
from synapse.rest.admin.rooms import (
AdminRoomHierarchy,
BlockRoomRestServlet,
DeleteRoomStatusByDeleteIdRestServlet,
DeleteRoomStatusByRoomIdRestServlet,
Expand Down Expand Up @@ -342,6 +343,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)
AdminRoomHierarchy(hs).register(http_server)
EventRestServlet(hs).register(http_server)


Expand Down
37 changes: 37 additions & 0 deletions synapse/rest/admin/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,43 @@
logger = logging.getLogger(__name__)


class AdminRoomHierarchy(RestServlet):
"""
Given a room, returns room details on that room and any space children of the provided room.
Does not reach out over federation to fetch details information about any remote rooms which
the server is not currently participating in, returning only the room id of those rooms
"""

PATTERNS = admin_patterns("/rooms/(?P<room_id>[^/]*)/hierarchy$")

def __init__(self, hs: "HomeServer"):
self._auth = hs.get_auth()
self._room_summary_handler = hs.get_room_summary_handler()
self._store = hs.get_datastores().main
self._storage_controllers = hs.get_storage_controllers()

async def on_GET(
self, request: SynapseRequest, room_id: str
) -> Tuple[int, JsonDict]:
requester = await self._auth.get_user_by_req(request)
await assert_user_is_admin(self._auth, requester)

max_depth = parse_integer(request, "max_depth")
limit = parse_integer(request, "limit")

room_entry_summary = await self._room_summary_handler.get_room_hierarchy(
requester,
room_id,
omit_remote_room_hierarchy=True, # We omit details about remote rooms because we only care about managing rooms local to the homeserver. This also immensely helps with the response time of the endpoint since we don't need to reach out over federation. There is a trade-off as this will leave holes where information about public/peekable remote rooms the server is not participating in will be omitted.
admin_skip_room_visibility_check=True,
max_depth=max_depth,
limit=limit,
from_token=parse_string(request, "from"),
Copy link
Contributor

Choose a reason for hiding this comment

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

from_token being a string instead of actual token seems like a mistake to me but this is prior art ⏩

)

return HTTPStatus.OK, room_entry_summary


class RoomRestV2Servlet(RestServlet):
"""Delete a room from server asynchronously with a background task.
Expand Down
Loading
Loading