Skip to content

Commit 8551e0f

Browse files
Allow suspended users to be auto-joined to server notice rooms (#18750)
1 parent 25289b6 commit 8551e0f

5 files changed

Lines changed: 309 additions & 54 deletions

File tree

changelog.d/18750.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a long-standing bug where suspended users could not have server notices sent to them (a 403 was returned to the admin).

synapse/handlers/message.py

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -642,38 +642,46 @@ async def create_event(
642642
"""
643643
await self.auth_blocking.check_auth_blocking(requester=requester)
644644

645-
requester_suspended = await self.store.get_user_suspended_status(
646-
requester.user.to_string()
645+
# The requester may be a regular user, but puppeted by the server.
646+
request_by_server = (
647+
requester.authenticated_entity == self.hs.config.server.server_name
647648
)
648-
if requester_suspended:
649-
# We want to allow suspended users to perform "corrective" actions
650-
# asked of them by server admins, such as redact their messages and
651-
# leave rooms.
652-
if event_dict["type"] in ["m.room.redaction", "m.room.member"]:
653-
if event_dict["type"] == "m.room.redaction":
654-
event = await self.store.get_event(
655-
event_dict["content"]["redacts"], allow_none=True
656-
)
657-
if event:
658-
if event.sender != requester.user.to_string():
649+
650+
# If the request is initiated by the server, ignore whether the
651+
# requester or target is suspended.
652+
if not request_by_server:
653+
requester_suspended = await self.store.get_user_suspended_status(
654+
requester.user.to_string()
655+
)
656+
if requester_suspended:
657+
# We want to allow suspended users to perform "corrective" actions
658+
# asked of them by server admins, such as redact their messages and
659+
# leave rooms.
660+
if event_dict["type"] in ["m.room.redaction", "m.room.member"]:
661+
if event_dict["type"] == "m.room.redaction":
662+
event = await self.store.get_event(
663+
event_dict["content"]["redacts"], allow_none=True
664+
)
665+
if event:
666+
if event.sender != requester.user.to_string():
667+
raise SynapseError(
668+
403,
669+
"You can only redact your own events while account is suspended.",
670+
Codes.USER_ACCOUNT_SUSPENDED,
671+
)
672+
if event_dict["type"] == "m.room.member":
673+
if event_dict["content"]["membership"] != "leave":
659674
raise SynapseError(
660675
403,
661-
"You can only redact your own events while account is suspended.",
676+
"Changing membership while account is suspended is not allowed.",
662677
Codes.USER_ACCOUNT_SUSPENDED,
663678
)
664-
if event_dict["type"] == "m.room.member":
665-
if event_dict["content"]["membership"] != "leave":
666-
raise SynapseError(
667-
403,
668-
"Changing membership while account is suspended is not allowed.",
669-
Codes.USER_ACCOUNT_SUSPENDED,
670-
)
671-
else:
672-
raise SynapseError(
673-
403,
674-
"Sending messages while account is suspended is not allowed.",
675-
Codes.USER_ACCOUNT_SUSPENDED,
676-
)
679+
else:
680+
raise SynapseError(
681+
403,
682+
"Sending messages while account is suspended is not allowed.",
683+
Codes.USER_ACCOUNT_SUSPENDED,
684+
)
677685

678686
if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "":
679687
room_version_id = event_dict["content"]["room_version"]

synapse/handlers/room_member.py

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -746,35 +746,41 @@ async def update_membership_locked(
746746
and requester.user.to_string() == self._server_notices_mxid
747747
)
748748

749-
requester_suspended = await self.store.get_user_suspended_status(
750-
requester.user.to_string()
751-
)
752-
if action == Membership.INVITE and requester_suspended:
753-
raise SynapseError(
754-
403,
755-
"Sending invites while account is suspended is not allowed.",
756-
Codes.USER_ACCOUNT_SUSPENDED,
749+
# The requester may be a regular user, but puppeted by the server.
750+
request_by_server = requester.authenticated_entity == self._server_name
751+
752+
# If the request is initiated by the server, ignore whether the
753+
# requester or target is suspended.
754+
if not request_by_server:
755+
requester_suspended = await self.store.get_user_suspended_status(
756+
requester.user.to_string()
757757
)
758+
if action == Membership.INVITE and requester_suspended:
759+
raise SynapseError(
760+
403,
761+
"Sending invites while account is suspended is not allowed.",
762+
Codes.USER_ACCOUNT_SUSPENDED,
763+
)
758764

759-
if target.to_string() != requester.user.to_string():
760-
target_suspended = await self.store.get_user_suspended_status(
761-
target.to_string()
762-
)
763-
else:
764-
target_suspended = requester_suspended
765+
if target.to_string() != requester.user.to_string():
766+
target_suspended = await self.store.get_user_suspended_status(
767+
target.to_string()
768+
)
769+
else:
770+
target_suspended = requester_suspended
765771

766-
if action == Membership.JOIN and target_suspended:
767-
raise SynapseError(
768-
403,
769-
"Joining rooms while account is suspended is not allowed.",
770-
Codes.USER_ACCOUNT_SUSPENDED,
771-
)
772-
if action == Membership.KNOCK and target_suspended:
773-
raise SynapseError(
774-
403,
775-
"Knocking on rooms while account is suspended is not allowed.",
776-
Codes.USER_ACCOUNT_SUSPENDED,
777-
)
772+
if action == Membership.JOIN and target_suspended:
773+
raise SynapseError(
774+
403,
775+
"Joining rooms while account is suspended is not allowed.",
776+
Codes.USER_ACCOUNT_SUSPENDED,
777+
)
778+
if action == Membership.KNOCK and target_suspended:
779+
raise SynapseError(
780+
403,
781+
"Knocking on rooms while account is suspended is not allowed.",
782+
Codes.USER_ACCOUNT_SUSPENDED,
783+
)
778784

779785
if (
780786
not self.allow_per_room_profiles and not is_requester_server_notices_user

tests/rest/client/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def invite(
185185
def join(
186186
self,
187187
room: str,
188-
user: Optional[str] = None,
188+
user: str,
189189
expect_code: int = HTTPStatus.OK,
190190
tok: Optional[str] = None,
191191
appservice_user_id: Optional[str] = None,

tests/server_notices/__init__.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
#
15+
16+
from twisted.test.proto_helpers import MemoryReactor
17+
18+
import synapse.rest.admin
19+
from synapse.rest.client import login, room, sync
20+
from synapse.server import HomeServer
21+
from synapse.types import JsonDict
22+
from synapse.util import Clock
23+
24+
from tests import unittest
25+
from tests.unittest import override_config
26+
from tests.utils import default_config
27+
28+
DEFAULT_SERVER_NOTICES_CONFIG = {
29+
"system_mxid_localpart": "notices",
30+
"system_mxid_display_name": "test display name",
31+
"system_mxid_avatar_url": None,
32+
"room_name": "Server Notices",
33+
"auto_join": False,
34+
}
35+
36+
37+
class ServerNoticesTests(unittest.HomeserverTestCase):
38+
servlets = [
39+
sync.register_servlets,
40+
synapse.rest.admin.register_servlets,
41+
login.register_servlets,
42+
room.register_servlets,
43+
]
44+
45+
def default_config(self) -> JsonDict:
46+
config = default_config("test")
47+
48+
config.update({"server_notices": DEFAULT_SERVER_NOTICES_CONFIG})
49+
50+
# apply any additional config which was specified via the override_config
51+
# decorator.
52+
if self._extra_config is not None:
53+
config.update(self._extra_config)
54+
55+
return config
56+
57+
def prepare(
58+
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
59+
) -> None:
60+
self._admin_user_id = self.register_user(
61+
"server_notices_admin", "abc123", admin=True
62+
)
63+
self._admin_user_access_token = self.login("server_notices_admin", "abc123")
64+
65+
self._test_user_id = self.register_user("server_notices_test_user", "abc123")
66+
self._test_user_access_token = self.login("server_notices_test_user", "abc123")
67+
68+
self._server_notice_content = {
69+
"msgtype": "m.text",
70+
"formatted_body": "<p>Do the hussle.</p>",
71+
"body": "Do the hussle.",
72+
"format": "org.matrix.custom.html",
73+
}
74+
75+
def _send_server_notice(
76+
self,
77+
admin_access_token: str,
78+
target_user_id: str,
79+
notice_content: JsonDict,
80+
) -> None:
81+
# Send a server notice.
82+
channel = self.make_request(
83+
"POST",
84+
"/_synapse/admin/v1/send_server_notice",
85+
content={
86+
"user_id": target_user_id,
87+
"content": notice_content,
88+
},
89+
access_token=admin_access_token,
90+
)
91+
self.assertEqual(channel.code, 200, channel.json_body)
92+
93+
def _check_user_received_server_notice(
94+
self,
95+
target_user_id: str,
96+
target_access_token: str,
97+
expected_content: JsonDict,
98+
user_accepts_invite: bool,
99+
) -> None:
100+
# Have the target user sync.
101+
channel = self.make_request(
102+
"GET", "/_matrix/client/v3/sync", access_token=target_access_token
103+
)
104+
self.assertEqual(channel.code, 200, channel.json_body)
105+
sync_body = channel.json_body
106+
107+
if user_accepts_invite:
108+
# Get the Room ID to join
109+
room_id = list(sync_body["rooms"]["invite"].keys())[0]
110+
111+
# Join the room
112+
self.helper.join(room_id, target_user_id, tok=target_access_token)
113+
114+
for _ in range(5):
115+
# Sync until we're joined to the room.
116+
channel = self.make_request(
117+
"GET", "/_matrix/client/v3/sync", access_token=target_access_token
118+
)
119+
self.assertEqual(channel.code, 200, channel.json_body)
120+
sync_body = channel.json_body
121+
122+
if "join" in sync_body["rooms"] and len(sync_body["rooms"]["join"]) > 0:
123+
# Retrieve the server notices message.
124+
room_id = list(sync_body["rooms"]["join"].keys())[0]
125+
room = sync_body["rooms"]["join"][room_id]
126+
messages = [
127+
x
128+
for x in room["timeline"]["events"]
129+
if x["type"] == "m.room.message"
130+
]
131+
break
132+
133+
# Sleep and try again.
134+
self.clock.sleep(0.1)
135+
else:
136+
self.fail(
137+
f"Failed to join the server notices room. No 'join' field in sync_body['rooms']: {sync_body['rooms']}"
138+
)
139+
140+
# Should be the expected server notices content.
141+
self.assertDictEqual(messages[-1]["content"], expected_content)
142+
143+
def test_send_server_notice(self) -> None:
144+
"""
145+
Test the happy path of sending a server notice to a user.
146+
"""
147+
# Send a server notice. The server notice room does not yet exist.
148+
self._send_server_notice(
149+
self._admin_user_access_token,
150+
self._test_user_id,
151+
self._server_notice_content,
152+
)
153+
154+
self._check_user_received_server_notice(
155+
self._test_user_id,
156+
self._test_user_access_token,
157+
self._server_notice_content,
158+
# User must accept the invite manually.
159+
True,
160+
)
161+
162+
# Send another server notice. In this case, the room already exists.
163+
self._send_server_notice(
164+
self._admin_user_access_token,
165+
self._test_user_id,
166+
self._server_notice_content,
167+
)
168+
169+
self._check_user_received_server_notice(
170+
self._test_user_id,
171+
self._test_user_access_token,
172+
self._server_notice_content,
173+
# User is already in the room, no need to join it.
174+
False,
175+
)
176+
177+
@override_config(
178+
{
179+
"server_notices": {
180+
**DEFAULT_SERVER_NOTICES_CONFIG,
181+
"auto_join": True,
182+
}
183+
}
184+
)
185+
def test_send_server_notice_auto_join(self) -> None:
186+
"""
187+
Test the happy path of sending a server notice to a user, with auto_join enabled.
188+
"""
189+
# Send a server notice. The server notice room does not yet exist.
190+
self._send_server_notice(
191+
self._admin_user_access_token,
192+
self._test_user_id,
193+
self._server_notice_content,
194+
)
195+
196+
self._check_user_received_server_notice(
197+
self._test_user_id,
198+
self._test_user_access_token,
199+
self._server_notice_content,
200+
# User does not need to join the room manually. They should be auto-joined.
201+
False,
202+
)
203+
204+
@override_config(
205+
{
206+
"server_notices": {
207+
**DEFAULT_SERVER_NOTICES_CONFIG,
208+
"auto_join": True,
209+
}
210+
}
211+
)
212+
def test_send_server_notice_suspended_user_auto_join(self) -> None:
213+
"""Test sending a server notice to a user that's suspended, with auto-join enabled.
214+
215+
This is a regression test for https://github.com/element-hq/synapse/pull/18750, where
216+
previously the suspended user would not be allowed to join the server notices room.
217+
"""
218+
# Suspend the target user.
219+
channel = self.make_request(
220+
"PUT",
221+
f"/_synapse/admin/v1/suspend/{self._test_user_id}",
222+
content={"suspend": True},
223+
access_token=self._admin_user_access_token,
224+
)
225+
self.assertEqual(channel.code, 200, channel.json_body)
226+
227+
# Send a server notice. The server notices room will be created and the user auto-joined.
228+
self._send_server_notice(
229+
self._admin_user_access_token,
230+
self._test_user_id,
231+
self._server_notice_content,
232+
)
233+
234+
self._check_user_received_server_notice(
235+
self._test_user_id,
236+
self._test_user_access_token,
237+
self._server_notice_content,
238+
# User does not need to join the room manually. They should be auto-joined.
239+
False,
240+
)

0 commit comments

Comments
 (0)