Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 15382b1

Browse files
Add third_party module callbacks to check if a user can delete a room and deactivate a user (#12028)
* Add check_can_deactivate_user * Add check_can_shutdown_rooms * Documentation * callbacks, not functions * Various suggested tweaks * Add tests for test_check_can_shutdown_room and test_check_can_deactivate_user * Update check_can_deactivate_user to not take a Requester * Fix check_can_shutdown_room docs * Renegade and use `by_admin` instead of `admin_user_id` * fix lint * Update docs/modules/third_party_rules_callbacks.md Co-authored-by: Brendan Abolivier <babolivier@matrix.org> * Update docs/modules/third_party_rules_callbacks.md Co-authored-by: Brendan Abolivier <babolivier@matrix.org> * Update docs/modules/third_party_rules_callbacks.md Co-authored-by: Brendan Abolivier <babolivier@matrix.org> * Update docs/modules/third_party_rules_callbacks.md Co-authored-by: Brendan Abolivier <babolivier@matrix.org> Co-authored-by: Brendan Abolivier <babolivier@matrix.org>
1 parent 690cb4f commit 15382b1

File tree

8 files changed

+254
-1
lines changed

8 files changed

+254
-1
lines changed

changelog.d/12028.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add third-party rules rules callbacks `check_can_shutdown_room` and `check_can_deactivate_user`.

docs/modules/third_party_rules_callbacks.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,49 @@ deny an incoming event, see [`check_event_for_spam`](spam_checker_callbacks.md#c
148148

149149
If multiple modules implement this callback, Synapse runs them all in order.
150150

151+
### `check_can_shutdown_room`
152+
153+
_First introduced in Synapse v1.55.0_
154+
155+
```python
156+
async def check_can_shutdown_room(
157+
user_id: str, room_id: str,
158+
) -> bool:
159+
```
160+
161+
Called when an admin user requests the shutdown of a room. The module must return a
162+
boolean indicating whether the shutdown can go through. If the callback returns `False`,
163+
the shutdown will not proceed and the caller will see a `M_FORBIDDEN` error.
164+
165+
If multiple modules implement this callback, they will be considered in order. If a
166+
callback returns `True`, Synapse falls through to the next one. The value of the first
167+
callback that does not return `True` will be used. If this happens, Synapse will not call
168+
any of the subsequent implementations of this callback.
169+
170+
### `check_can_deactivate_user`
171+
172+
_First introduced in Synapse v1.55.0_
173+
174+
```python
175+
async def check_can_deactivate_user(
176+
user_id: str, by_admin: bool,
177+
) -> bool:
178+
```
179+
180+
Called when the deactivation of a user is requested. User deactivation can be
181+
performed by an admin or the user themselves, so developers are encouraged to check the
182+
requester when implementing this callback. The module must return a
183+
boolean indicating whether the deactivation can go through. If the callback returns `False`,
184+
the deactivation will not proceed and the caller will see a `M_FORBIDDEN` error.
185+
186+
The module is passed two parameters, `user_id` which is the ID of the user being deactivated, and `by_admin` which is `True` if the request is made by a serve admin, and `False` otherwise.
187+
188+
If multiple modules implement this callback, they will be considered in order. If a
189+
callback returns `True`, Synapse falls through to the next one. The value of the first
190+
callback that does not return `True` will be used. If this happens, Synapse will not call
191+
any of the subsequent implementations of this callback.
192+
193+
151194
### `on_profile_update`
152195

153196
_First introduced in Synapse v1.54.0_

synapse/events/third_party_rules.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
[str, StateMap[EventBase], str], Awaitable[bool]
3939
]
4040
ON_NEW_EVENT_CALLBACK = Callable[[EventBase, StateMap[EventBase]], Awaitable]
41+
CHECK_CAN_SHUTDOWN_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]]
42+
CHECK_CAN_DEACTIVATE_USER_CALLBACK = Callable[[str, bool], Awaitable[bool]]
4143
ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool, bool], Awaitable]
4244
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable]
4345

@@ -157,6 +159,12 @@ def __init__(self, hs: "HomeServer"):
157159
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
158160
] = []
159161
self._on_new_event_callbacks: List[ON_NEW_EVENT_CALLBACK] = []
162+
self._check_can_shutdown_room_callbacks: List[
163+
CHECK_CAN_SHUTDOWN_ROOM_CALLBACK
164+
] = []
165+
self._check_can_deactivate_user_callbacks: List[
166+
CHECK_CAN_DEACTIVATE_USER_CALLBACK
167+
] = []
160168
self._on_profile_update_callbacks: List[ON_PROFILE_UPDATE_CALLBACK] = []
161169
self._on_user_deactivation_status_changed_callbacks: List[
162170
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
@@ -173,6 +181,8 @@ def register_third_party_rules_callbacks(
173181
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
174182
] = None,
175183
on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None,
184+
check_can_shutdown_room: Optional[CHECK_CAN_SHUTDOWN_ROOM_CALLBACK] = None,
185+
check_can_deactivate_user: Optional[CHECK_CAN_DEACTIVATE_USER_CALLBACK] = None,
176186
on_profile_update: Optional[ON_PROFILE_UPDATE_CALLBACK] = None,
177187
on_user_deactivation_status_changed: Optional[
178188
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
@@ -198,6 +208,11 @@ def register_third_party_rules_callbacks(
198208
if on_new_event is not None:
199209
self._on_new_event_callbacks.append(on_new_event)
200210

211+
if check_can_shutdown_room is not None:
212+
self._check_can_shutdown_room_callbacks.append(check_can_shutdown_room)
213+
214+
if check_can_deactivate_user is not None:
215+
self._check_can_deactivate_user_callbacks.append(check_can_deactivate_user)
201216
if on_profile_update is not None:
202217
self._on_profile_update_callbacks.append(on_profile_update)
203218

@@ -369,6 +384,46 @@ async def on_new_event(self, event_id: str) -> None:
369384
"Failed to run module API callback %s: %s", callback, e
370385
)
371386

387+
async def check_can_shutdown_room(self, user_id: str, room_id: str) -> bool:
388+
"""Intercept requests to shutdown a room. If `False` is returned, the
389+
room must not be shut down.
390+
391+
Args:
392+
requester: The ID of the user requesting the shutdown.
393+
room_id: The ID of the room.
394+
"""
395+
for callback in self._check_can_shutdown_room_callbacks:
396+
try:
397+
if await callback(user_id, room_id) is False:
398+
return False
399+
except Exception as e:
400+
logger.exception(
401+
"Failed to run module API callback %s: %s", callback, e
402+
)
403+
return True
404+
405+
async def check_can_deactivate_user(
406+
self,
407+
user_id: str,
408+
by_admin: bool,
409+
) -> bool:
410+
"""Intercept requests to deactivate a user. If `False` is returned, the
411+
user should not be deactivated.
412+
413+
Args:
414+
requester
415+
user_id: The ID of the room.
416+
"""
417+
for callback in self._check_can_deactivate_user_callbacks:
418+
try:
419+
if await callback(user_id, by_admin) is False:
420+
return False
421+
except Exception as e:
422+
logger.exception(
423+
"Failed to run module API callback %s: %s", callback, e
424+
)
425+
return True
426+
372427
async def _get_state_map_for_room(self, room_id: str) -> StateMap[EventBase]:
373428
"""Given a room ID, return the state events of that room.
374429

synapse/handlers/deactivate_account.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from synapse.api.errors import SynapseError
1919
from synapse.metrics.background_process_metrics import run_as_background_process
20-
from synapse.types import Requester, UserID, create_requester
20+
from synapse.types import Codes, Requester, UserID, create_requester
2121

2222
if TYPE_CHECKING:
2323
from synapse.server import HomeServer
@@ -42,6 +42,7 @@ def __init__(self, hs: "HomeServer"):
4242

4343
# Flag that indicates whether the process to part users from rooms is running
4444
self._user_parter_running = False
45+
self._third_party_rules = hs.get_third_party_event_rules()
4546

4647
# Start the user parter loop so it can resume parting users from rooms where
4748
# it left off (if it has work left to do).
@@ -74,6 +75,15 @@ async def deactivate_account(
7475
Returns:
7576
True if identity server supports removing threepids, otherwise False.
7677
"""
78+
79+
# Check if this user can be deactivated
80+
if not await self._third_party_rules.check_can_deactivate_user(
81+
user_id, by_admin
82+
):
83+
raise SynapseError(
84+
403, "Deactivation of this user is forbidden", Codes.FORBIDDEN
85+
)
86+
7787
# FIXME: Theoretically there is a race here wherein user resets
7888
# password using threepid.
7989

synapse/handlers/room.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,7 @@ def __init__(self, hs: "HomeServer"):
14751475
self.room_member_handler = hs.get_room_member_handler()
14761476
self._room_creation_handler = hs.get_room_creation_handler()
14771477
self._replication = hs.get_replication_data_handler()
1478+
self._third_party_rules = hs.get_third_party_event_rules()
14781479
self.event_creation_handler = hs.get_event_creation_handler()
14791480
self.store = hs.get_datastores().main
14801481

@@ -1548,6 +1549,13 @@ async def shutdown_room(
15481549
if not RoomID.is_valid(room_id):
15491550
raise SynapseError(400, "%s is not a legal room ID" % (room_id,))
15501551

1552+
if not await self._third_party_rules.check_can_shutdown_room(
1553+
requester_user_id, room_id
1554+
):
1555+
raise SynapseError(
1556+
403, "Shutdown of this room is forbidden", Codes.FORBIDDEN
1557+
)
1558+
15511559
# Action the block first (even if the room doesn't exist yet)
15521560
if block:
15531561
# This will work even if the room is already blocked, but that is

synapse/module_api/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
USER_MAY_SEND_3PID_INVITE_CALLBACK,
5555
)
5656
from synapse.events.third_party_rules import (
57+
CHECK_CAN_DEACTIVATE_USER_CALLBACK,
58+
CHECK_CAN_SHUTDOWN_ROOM_CALLBACK,
5759
CHECK_EVENT_ALLOWED_CALLBACK,
5860
CHECK_THREEPID_CAN_BE_INVITED_CALLBACK,
5961
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK,
@@ -283,6 +285,8 @@ def register_third_party_rules_callbacks(
283285
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
284286
] = None,
285287
on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None,
288+
check_can_shutdown_room: Optional[CHECK_CAN_SHUTDOWN_ROOM_CALLBACK] = None,
289+
check_can_deactivate_user: Optional[CHECK_CAN_DEACTIVATE_USER_CALLBACK] = None,
286290
on_profile_update: Optional[ON_PROFILE_UPDATE_CALLBACK] = None,
287291
on_user_deactivation_status_changed: Optional[
288292
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
@@ -298,6 +302,8 @@ def register_third_party_rules_callbacks(
298302
check_threepid_can_be_invited=check_threepid_can_be_invited,
299303
check_visibility_can_be_modified=check_visibility_can_be_modified,
300304
on_new_event=on_new_event,
305+
check_can_shutdown_room=check_can_shutdown_room,
306+
check_can_deactivate_user=check_can_deactivate_user,
301307
on_profile_update=on_profile_update,
302308
on_user_deactivation_status_changed=on_user_deactivation_status_changed,
303309
)

synapse/rest/admin/rooms.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(self, hs: "HomeServer"):
6767
self._auth = hs.get_auth()
6868
self._store = hs.get_datastores().main
6969
self._pagination_handler = hs.get_pagination_handler()
70+
self._third_party_rules = hs.get_third_party_event_rules()
7071

7172
async def on_DELETE(
7273
self, request: SynapseRequest, room_id: str
@@ -106,6 +107,14 @@ async def on_DELETE(
106107
HTTPStatus.BAD_REQUEST, "%s is not a legal room ID" % (room_id,)
107108
)
108109

110+
# Check this here, as otherwise we'll only fail after the background job has been started.
111+
if not await self._third_party_rules.check_can_shutdown_room(
112+
requester.user.to_string(), room_id
113+
):
114+
raise SynapseError(
115+
403, "Shutdown of this room is forbidden", Codes.FORBIDDEN
116+
)
117+
109118
delete_id = self._pagination_handler.start_shutdown_and_purge_room(
110119
room_id=room_id,
111120
new_room_user_id=content.get("new_room_user_id"),

tests/rest/client/test_third_party_rules.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,124 @@ def test_on_user_deactivation_status_changed_admin(self) -> None:
775775
self.assertEqual(args[0], user_id)
776776
self.assertFalse(args[1])
777777
self.assertTrue(args[2])
778+
779+
def test_check_can_deactivate_user(self) -> None:
780+
"""Tests that the on_user_deactivation_status_changed module callback is called
781+
correctly when processing a user's deactivation.
782+
"""
783+
# Register a mocked callback.
784+
deactivation_mock = Mock(return_value=make_awaitable(False))
785+
third_party_rules = self.hs.get_third_party_event_rules()
786+
third_party_rules._check_can_deactivate_user_callbacks.append(
787+
deactivation_mock,
788+
)
789+
790+
# Register a user that we'll deactivate.
791+
user_id = self.register_user("altan", "password")
792+
tok = self.login("altan", "password")
793+
794+
# Deactivate that user.
795+
channel = self.make_request(
796+
"POST",
797+
"/_matrix/client/v3/account/deactivate",
798+
{
799+
"auth": {
800+
"type": LoginType.PASSWORD,
801+
"password": "password",
802+
"identifier": {
803+
"type": "m.id.user",
804+
"user": user_id,
805+
},
806+
},
807+
"erase": True,
808+
},
809+
access_token=tok,
810+
)
811+
812+
# Check that the deactivation was blocked
813+
self.assertEqual(channel.code, 403, channel.json_body)
814+
815+
# Check that the mock was called once.
816+
deactivation_mock.assert_called_once()
817+
args = deactivation_mock.call_args[0]
818+
819+
# Check that the mock was called with the right user ID
820+
self.assertEqual(args[0], user_id)
821+
822+
# Check that the request was not made by an admin
823+
self.assertEqual(args[1], False)
824+
825+
def test_check_can_deactivate_user_admin(self) -> None:
826+
"""Tests that the on_user_deactivation_status_changed module callback is called
827+
correctly when processing a user's deactivation triggered by a server admin.
828+
"""
829+
# Register a mocked callback.
830+
deactivation_mock = Mock(return_value=make_awaitable(False))
831+
third_party_rules = self.hs.get_third_party_event_rules()
832+
third_party_rules._check_can_deactivate_user_callbacks.append(
833+
deactivation_mock,
834+
)
835+
836+
# Register an admin user.
837+
self.register_user("admin", "password", admin=True)
838+
admin_tok = self.login("admin", "password")
839+
840+
# Register a user that we'll deactivate.
841+
user_id = self.register_user("altan", "password")
842+
843+
# Deactivate the user.
844+
channel = self.make_request(
845+
"PUT",
846+
"/_synapse/admin/v2/users/%s" % user_id,
847+
{"deactivated": True},
848+
access_token=admin_tok,
849+
)
850+
851+
# Check that the deactivation was blocked
852+
self.assertEqual(channel.code, 403, channel.json_body)
853+
854+
# Check that the mock was called once.
855+
deactivation_mock.assert_called_once()
856+
args = deactivation_mock.call_args[0]
857+
858+
# Check that the mock was called with the right user ID
859+
self.assertEqual(args[0], user_id)
860+
861+
# Check that the mock was made by an admin
862+
self.assertEqual(args[1], True)
863+
864+
def test_check_can_shutdown_room(self) -> None:
865+
"""Tests that the check_can_shutdown_room module callback is called
866+
correctly when processing an admin's shutdown room request.
867+
"""
868+
# Register a mocked callback.
869+
shutdown_mock = Mock(return_value=make_awaitable(False))
870+
third_party_rules = self.hs.get_third_party_event_rules()
871+
third_party_rules._check_can_shutdown_room_callbacks.append(
872+
shutdown_mock,
873+
)
874+
875+
# Register an admin user.
876+
admin_user_id = self.register_user("admin", "password", admin=True)
877+
admin_tok = self.login("admin", "password")
878+
879+
# Shutdown the room.
880+
channel = self.make_request(
881+
"DELETE",
882+
"/_synapse/admin/v2/rooms/%s" % self.room_id,
883+
{},
884+
access_token=admin_tok,
885+
)
886+
887+
# Check that the shutdown was blocked
888+
self.assertEqual(channel.code, 403, channel.json_body)
889+
890+
# Check that the mock was called once.
891+
shutdown_mock.assert_called_once()
892+
args = shutdown_mock.call_args[0]
893+
894+
# Check that the mock was called with the right user ID
895+
self.assertEqual(args[0], admin_user_id)
896+
897+
# Check that the mock was called with the right room ID
898+
self.assertEqual(args[1], self.room_id)

0 commit comments

Comments
 (0)