Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for MSC3823 - Account Suspension #17051

Merged
merged 8 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/17051.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add preliminary support for [MSC3823](https://github.com/matrix-org/matrix-spec-proposals/pull/3823) - Account Suspension.
2 changes: 1 addition & 1 deletion synapse/_scripts/synapse_port_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"redactions": ["have_censored"],
"room_stats_state": ["is_federatable"],
"rooms": ["is_public", "has_auth_chain_index"],
"users": ["shadow_banned", "approved", "locked"],
"users": ["shadow_banned", "approved", "locked", "suspended"],
"un_partial_stated_event_stream": ["rejection_status_changed"],
"users_who_share_rooms": ["share_private"],
"per_user_experimental_features": ["enabled"],
Expand Down
30 changes: 30 additions & 0 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,36 @@ async def update_membership_locked(
and requester.user.to_string() == self._server_notices_mxid
)

requester_suspended = await self.store.get_user_suspended_status(
requester.user.to_string()
)
if action == Membership.INVITE and requester_suspended:
raise SynapseError(
403,
"Sending invites while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)

if target.to_string() != requester.user.to_string():
target_suspended = await self.store.get_user_suspended_status(
target.to_string()
)
else:
target_suspended = requester_suspended

if action == Membership.JOIN and target_suspended:
raise SynapseError(
403,
"Joining rooms while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)
if action == Membership.KNOCK and target_suspended:
raise SynapseError(
403,
"Knocking on rooms while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)

if (
not self.allow_per_room_profiles and not is_requester_server_notices_user
) or requester.shadow_banned:
Expand Down
55 changes: 54 additions & 1 deletion synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
consent_server_notice_sent, appservice_id, creation_ts, user_type,
deactivated, COALESCE(shadow_banned, FALSE) AS shadow_banned,
COALESCE(approved, TRUE) AS approved,
COALESCE(locked, FALSE) AS locked
COALESCE(locked, FALSE) AS locked,
suspended
FROM users
WHERE name = ?
""",
Expand All @@ -261,6 +262,7 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
shadow_banned,
approved,
locked,
suspended,
) = row

return UserInfo(
Expand All @@ -277,6 +279,7 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[UserInfo]:
user_type=user_type,
approved=bool(approved),
locked=bool(locked),
suspended=bool(suspended),
)

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

@cached()
async def get_user_suspended_status(self, user_id: str) -> bool:
"""
Determine whether the user's account is suspended.
Args:
user_id: The user ID of the user in question
Returns:
True if the user's account is suspended, false if it is not suspended or
if the user ID cannot be found.
"""

res = await self.db_pool.simple_select_one_onecol(
table="users",
keyvalues={"name": user_id},
retcol="suspended",
allow_none=True,
desc="get_user_suspended",
)

return bool(res)

async def get_threepid_validation_session(
self,
medium: Optional[str],
Expand Down Expand Up @@ -2213,6 +2237,35 @@ def set_user_deactivated_status_txn(
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
txn.call_after(self.is_guest.invalidate, (user_id,))

async def set_user_suspended_status(self, user_id: str, suspended: bool) -> None:
"""
Set whether the user's account is suspended in the `users` table.

Args:
user_id: The user ID of the user in question
suspended: True if the user is suspended, false if not
"""
await self.db_pool.runInteraction(
"set_user_suspended_status",
self.set_user_suspended_status_txn,
user_id,
suspended,
)

def set_user_suspended_status_txn(
self, txn: LoggingTransaction, user_id: str, suspended: bool
) -> None:
self.db_pool.simple_update_one_txn(
txn=txn,
table="users",
keyvalues={"name": user_id},
updatevalues={"suspended": suspended},
)
self._invalidate_cache_and_stream(
txn, self.get_user_suspended_status, (user_id,)
)
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))

async def set_user_locked_status(self, user_id: str, locked: bool) -> None:
"""Set the `locked` property for the provided user to the provided value.

Expand Down
5 changes: 4 additions & 1 deletion synapse/storage/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#
#

SCHEMA_VERSION = 84 # remember to update the list below when updating
SCHEMA_VERSION = 85 # remember to update the list below when updating
"""Represents the expectations made by the codebase about the database schema

This should be incremented whenever the codebase changes its requirements on the
Expand Down Expand Up @@ -136,6 +136,9 @@
Changes in SCHEMA_VERSION = 84
- No longer assumes that `event_auth_chain_links` holds transitive links, and
so read operations must do graph traversal.

Changes in SCHEMA_VERSION = 85
- Add a column `suspended` to the `users` table
"""


Expand Down
14 changes: 14 additions & 0 deletions synapse/storage/schema/main/delta/85/01_add_suspended.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--
-- This file is licensed under the Affero General Public License (AGPL) version 3.
--
-- Copyright (C) 2024 New Vector, Ltd
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of the
-- License, or (at your option) any later version.
--
-- See the GNU Affero General Public License for more details:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.

ALTER TABLE users ADD COLUMN suspended BOOLEAN DEFAULT FALSE NOT NULL;
2 changes: 2 additions & 0 deletions synapse/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,7 @@ class UserInfo:
user_type: User type (None for normal user, 'support' and 'bot' other options).
approved: If the user has been "approved" to register on the server.
locked: Whether the user's account has been locked
suspended: Whether the user's account is currently suspended
"""

user_id: UserID
Expand All @@ -1171,6 +1172,7 @@ class UserInfo:
is_shadow_banned: bool
approved: bool
locked: bool
suspended: bool


class UserProfile(TypedDict):
Expand Down
69 changes: 66 additions & 3 deletions tests/rest/client/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@
from synapse.events import EventBase
from synapse.events.snapshot import EventContext
from synapse.rest import admin
from synapse.rest.client import account, directory, login, profile, register, room, sync
from synapse.rest.client import (
account,
directory,
knock,
login,
profile,
register,
room,
sync,
)
from synapse.server import HomeServer
from synapse.types import JsonDict, RoomAlias, UserID, create_requester
from synapse.util import Clock
Expand Down Expand Up @@ -733,7 +742,7 @@ def test_post_room_no_keys(self) -> None:
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
self.assertTrue("room_id" in channel.json_body)
assert channel.resource_usage is not None
self.assertEqual(32, channel.resource_usage.db_txn_count)
self.assertEqual(33, channel.resource_usage.db_txn_count)

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

def test_post_room_visibility_key(self) -> None:
# POST with visibility config key, expect new room id
Expand Down Expand Up @@ -1154,6 +1163,7 @@ class RoomJoinTestCase(RoomBase):
admin.register_servlets,
login.register_servlets,
room.register_servlets,
knock.register_servlets,
]

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

self.store = hs.get_datastores().main

def test_spam_checker_may_join_room_deprecated(self) -> None:
"""Tests that the user_may_join_room spam checker callback is correctly called
and blocks room joins when needed.
Expand Down Expand Up @@ -1317,6 +1329,57 @@ async def user_may_join_room(
expect_additional_fields=return_value[1],
)

def test_suspended_user_cannot_join_room(self) -> None:
# set the user as suspended
self.get_success(self.store.set_user_suspended_status(self.user2, True))

channel = self.make_request(
"POST", f"/join/{self.room1}", access_token=self.tok2
)
self.assertEqual(channel.code, 403)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)

channel = self.make_request(
"POST", f"/rooms/{self.room1}/join", access_token=self.tok2
)
self.assertEqual(channel.code, 403)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)

def test_suspended_user_cannot_knock_on_room(self) -> None:
# set the user as suspended
self.get_success(self.store.set_user_suspended_status(self.user2, True))

channel = self.make_request(
"POST",
f"/_matrix/client/v3/knock/{self.room1}",
access_token=self.tok2,
content={},
shorthand=False,
)
self.assertEqual(channel.code, 403)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)

def test_suspended_user_cannot_invite_to_room(self) -> None:
# set the user as suspended
self.get_success(self.store.set_user_suspended_status(self.user1, True))

# first user invites second user
channel = self.make_request(
"POST",
f"/rooms/{self.room1}/invite",
access_token=self.tok1,
content={"user_id": self.user2},
)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)


class RoomAppserviceTsParamTestCase(unittest.HomeserverTestCase):
servlets = [
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/test_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ def test_register(self) -> None:

self.assertEqual(
UserInfo(
# TODO(paul): Surely this field should be 'user_id', not 'name'
user_id=UserID.from_string(self.user_id),
is_admin=False,
is_guest=False,
Expand All @@ -57,6 +56,7 @@ def test_register(self) -> None:
locked=False,
is_shadow_banned=False,
approved=True,
suspended=False,
),
(self.get_success(self.store.get_user_by_id(self.user_id))),
)
Expand Down
Loading