Skip to content

Commit 37558d5

Browse files
authoredMay 1, 2024
Add support for MSC3823 - Account Suspension (#17051)
1 parent 0b358f8 commit 37558d5

File tree

9 files changed

+173
-7
lines changed

9 files changed

+173
-7
lines changed
 

‎changelog.d/17051.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add preliminary support for [MSC3823](https://github.com/matrix-org/matrix-spec-proposals/pull/3823) - Account Suspension.

‎synapse/_scripts/synapse_port_db.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
"redactions": ["have_censored"],
128128
"room_stats_state": ["is_federatable"],
129129
"rooms": ["is_public", "has_auth_chain_index"],
130-
"users": ["shadow_banned", "approved", "locked"],
130+
"users": ["shadow_banned", "approved", "locked", "suspended"],
131131
"un_partial_stated_event_stream": ["rejection_status_changed"],
132132
"users_who_share_rooms": ["share_private"],
133133
"per_user_experimental_features": ["enabled"],

‎synapse/handlers/room_member.py

+30
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,36 @@ async def update_membership_locked(
752752
and requester.user.to_string() == self._server_notices_mxid
753753
)
754754

755+
requester_suspended = await self.store.get_user_suspended_status(
756+
requester.user.to_string()
757+
)
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+
)
764+
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
771+
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+
)
784+
755785
if (
756786
not self.allow_per_room_profiles and not is_requester_server_notices_user
757787
) or requester.shadow_banned:

‎synapse/storage/databases/main/registration.py

+54-1
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
236236
consent_server_notice_sent, appservice_id, creation_ts, user_type,
237237
deactivated, COALESCE(shadow_banned, FALSE) AS shadow_banned,
238238
COALESCE(approved, TRUE) AS approved,
239-
COALESCE(locked, FALSE) AS locked
239+
COALESCE(locked, FALSE) AS locked,
240+
suspended
240241
FROM users
241242
WHERE name = ?
242243
""",
@@ -261,6 +262,7 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
261262
shadow_banned,
262263
approved,
263264
locked,
265+
suspended,
264266
) = row
265267

266268
return UserInfo(
@@ -277,6 +279,7 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
277279
user_type=user_type,
278280
approved=bool(approved),
279281
locked=bool(locked),
282+
suspended=bool(suspended),
280283
)
281284

282285
return await self.db_pool.runInteraction(
@@ -1180,6 +1183,27 @@ async def get_user_locked_status(self, user_id: str) -> bool:
11801183
# Convert the potential integer into a boolean.
11811184
return bool(res)
11821185

1186+
@cached()
1187+
async def get_user_suspended_status(self, user_id: str) -> bool:
1188+
"""
1189+
Determine whether the user's account is suspended.
1190+
Args:
1191+
user_id: The user ID of the user in question
1192+
Returns:
1193+
True if the user's account is suspended, false if it is not suspended or
1194+
if the user ID cannot be found.
1195+
"""
1196+
1197+
res = await self.db_pool.simple_select_one_onecol(
1198+
table="users",
1199+
keyvalues={"name": user_id},
1200+
retcol="suspended",
1201+
allow_none=True,
1202+
desc="get_user_suspended",
1203+
)
1204+
1205+
return bool(res)
1206+
11831207
async def get_threepid_validation_session(
11841208
self,
11851209
medium: Optional[str],
@@ -2213,6 +2237,35 @@ def set_user_deactivated_status_txn(
22132237
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
22142238
txn.call_after(self.is_guest.invalidate, (user_id,))
22152239

2240+
async def set_user_suspended_status(self, user_id: str, suspended: bool) -> None:
2241+
"""
2242+
Set whether the user's account is suspended in the `users` table.
2243+
2244+
Args:
2245+
user_id: The user ID of the user in question
2246+
suspended: True if the user is suspended, false if not
2247+
"""
2248+
await self.db_pool.runInteraction(
2249+
"set_user_suspended_status",
2250+
self.set_user_suspended_status_txn,
2251+
user_id,
2252+
suspended,
2253+
)
2254+
2255+
def set_user_suspended_status_txn(
2256+
self, txn: LoggingTransaction, user_id: str, suspended: bool
2257+
) -> None:
2258+
self.db_pool.simple_update_one_txn(
2259+
txn=txn,
2260+
table="users",
2261+
keyvalues={"name": user_id},
2262+
updatevalues={"suspended": suspended},
2263+
)
2264+
self._invalidate_cache_and_stream(
2265+
txn, self.get_user_suspended_status, (user_id,)
2266+
)
2267+
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
2268+
22162269
async def set_user_locked_status(self, user_id: str, locked: bool) -> None:
22172270
"""Set the `locked` property for the provided user to the provided value.
22182271

‎synapse/storage/schema/__init__.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#
2020
#
2121

22-
SCHEMA_VERSION = 84 # remember to update the list below when updating
22+
SCHEMA_VERSION = 85 # remember to update the list below when updating
2323
"""Represents the expectations made by the codebase about the database schema
2424
2525
This should be incremented whenever the codebase changes its requirements on the
@@ -136,6 +136,9 @@
136136
Changes in SCHEMA_VERSION = 84
137137
- No longer assumes that `event_auth_chain_links` holds transitive links, and
138138
so read operations must do graph traversal.
139+
140+
Changes in SCHEMA_VERSION = 85
141+
- Add a column `suspended` to the `users` table
139142
"""
140143

141144

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
--
2+
-- This file is licensed under the Affero General Public License (AGPL) version 3.
3+
--
4+
-- Copyright (C) 2024 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+
ALTER TABLE users ADD COLUMN suspended BOOLEAN DEFAULT FALSE NOT NULL;

‎synapse/types/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,7 @@ class UserInfo:
11561156
user_type: User type (None for normal user, 'support' and 'bot' other options).
11571157
approved: If the user has been "approved" to register on the server.
11581158
locked: Whether the user's account has been locked
1159+
suspended: Whether the user's account is currently suspended
11591160
"""
11601161

11611162
user_id: UserID
@@ -1171,6 +1172,7 @@ class UserInfo:
11711172
is_shadow_banned: bool
11721173
approved: bool
11731174
locked: bool
1175+
suspended: bool
11741176

11751177

11761178
class UserProfile(TypedDict):

‎tests/rest/client/test_rooms.py

+66-3
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,16 @@
4848
from synapse.events import EventBase
4949
from synapse.events.snapshot import EventContext
5050
from synapse.rest import admin
51-
from synapse.rest.client import account, directory, login, profile, register, room, sync
51+
from synapse.rest.client import (
52+
account,
53+
directory,
54+
knock,
55+
login,
56+
profile,
57+
register,
58+
room,
59+
sync,
60+
)
5261
from synapse.server import HomeServer
5362
from synapse.types import JsonDict, RoomAlias, UserID, create_requester
5463
from synapse.util import Clock
@@ -733,7 +742,7 @@ def test_post_room_no_keys(self) -> None:
733742
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
734743
self.assertTrue("room_id" in channel.json_body)
735744
assert channel.resource_usage is not None
736-
self.assertEqual(32, channel.resource_usage.db_txn_count)
745+
self.assertEqual(33, channel.resource_usage.db_txn_count)
737746

738747
def test_post_room_initial_state(self) -> None:
739748
# POST with initial_state config key, expect new room id
@@ -746,7 +755,7 @@ def test_post_room_initial_state(self) -> None:
746755
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
747756
self.assertTrue("room_id" in channel.json_body)
748757
assert channel.resource_usage is not None
749-
self.assertEqual(34, channel.resource_usage.db_txn_count)
758+
self.assertEqual(35, channel.resource_usage.db_txn_count)
750759

751760
def test_post_room_visibility_key(self) -> None:
752761
# POST with visibility config key, expect new room id
@@ -1154,6 +1163,7 @@ class RoomJoinTestCase(RoomBase):
11541163
admin.register_servlets,
11551164
login.register_servlets,
11561165
room.register_servlets,
1166+
knock.register_servlets,
11571167
]
11581168

11591169
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
@@ -1167,6 +1177,8 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
11671177
self.room2 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
11681178
self.room3 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
11691179

1180+
self.store = hs.get_datastores().main
1181+
11701182
def test_spam_checker_may_join_room_deprecated(self) -> None:
11711183
"""Tests that the user_may_join_room spam checker callback is correctly called
11721184
and blocks room joins when needed.
@@ -1317,6 +1329,57 @@ async def user_may_join_room(
13171329
expect_additional_fields=return_value[1],
13181330
)
13191331

1332+
def test_suspended_user_cannot_join_room(self) -> None:
1333+
# set the user as suspended
1334+
self.get_success(self.store.set_user_suspended_status(self.user2, True))
1335+
1336+
channel = self.make_request(
1337+
"POST", f"/join/{self.room1}", access_token=self.tok2
1338+
)
1339+
self.assertEqual(channel.code, 403)
1340+
self.assertEqual(
1341+
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
1342+
)
1343+
1344+
channel = self.make_request(
1345+
"POST", f"/rooms/{self.room1}/join", access_token=self.tok2
1346+
)
1347+
self.assertEqual(channel.code, 403)
1348+
self.assertEqual(
1349+
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
1350+
)
1351+
1352+
def test_suspended_user_cannot_knock_on_room(self) -> None:
1353+
# set the user as suspended
1354+
self.get_success(self.store.set_user_suspended_status(self.user2, True))
1355+
1356+
channel = self.make_request(
1357+
"POST",
1358+
f"/_matrix/client/v3/knock/{self.room1}",
1359+
access_token=self.tok2,
1360+
content={},
1361+
shorthand=False,
1362+
)
1363+
self.assertEqual(channel.code, 403)
1364+
self.assertEqual(
1365+
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
1366+
)
1367+
1368+
def test_suspended_user_cannot_invite_to_room(self) -> None:
1369+
# set the user as suspended
1370+
self.get_success(self.store.set_user_suspended_status(self.user1, True))
1371+
1372+
# first user invites second user
1373+
channel = self.make_request(
1374+
"POST",
1375+
f"/rooms/{self.room1}/invite",
1376+
access_token=self.tok1,
1377+
content={"user_id": self.user2},
1378+
)
1379+
self.assertEqual(
1380+
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
1381+
)
1382+
13201383

13211384
class RoomAppserviceTsParamTestCase(unittest.HomeserverTestCase):
13221385
servlets = [

‎tests/storage/test_registration.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ def test_register(self) -> None:
4343

4444
self.assertEqual(
4545
UserInfo(
46-
# TODO(paul): Surely this field should be 'user_id', not 'name'
4746
user_id=UserID.from_string(self.user_id),
4847
is_admin=False,
4948
is_guest=False,
@@ -57,6 +56,7 @@ def test_register(self) -> None:
5756
locked=False,
5857
is_shadow_banned=False,
5958
approved=True,
59+
suspended=False,
6060
),
6161
(self.get_success(self.store.get_user_by_id(self.user_id))),
6262
)

0 commit comments

Comments
 (0)
Please sign in to comment.