Compare commits

...

10 commits

Author SHA1 Message Date
Shay 1dd22d4932
Merge f93d2618bf into adeedb7b7c 2024-06-21 21:00:33 +00:00
H. Shay f93d2618bf fix import changed by merge 2024-06-21 14:00:16 -07:00
H. Shay 2fe7d28a88 Merge branch 'develop' into shay/account_suspension_pt_2 2024-06-21 13:59:01 -07:00
H. Shay 4b4180bba5 add pydantic model + comment 2024-06-06 11:50:25 -07:00
H. Shay 7975a81e12 lint 2024-05-31 12:04:56 -07:00
Shay 9b56bf8497
Merge branch 'develop' into shay/account_suspension_pt_2 2024-05-31 12:01:45 -07:00
H. Shay fe4edc1cb2 newsfragment 2024-05-31 11:43:35 -07:00
H. Shay 07bf06935f tests 2024-05-31 11:36:56 -07:00
H. Shay 245e28f816 add admin API to suspend users + add experimental config option 2024-05-31 11:36:56 -07:00
H. Shay 060ba63922 prevent suspended users from sending messages, changing profile data and redacting messages other than their own 2024-05-31 11:36:56 -07:00
9 changed files with 287 additions and 0 deletions

View file

@ -0,0 +1 @@
Add support for [MSC823](https://github.com/matrix-org/matrix-spec-proposals/pull/3823) - Account suspension.

View file

@ -433,6 +433,10 @@ class ExperimentalConfig(Config):
("experimental", "msc4108_delegation_endpoint"), ("experimental", "msc4108_delegation_endpoint"),
) )
self.msc3823_account_suspension = experimental.get(
"msc3823_account_suspension", False
)
self.msc3916_authenticated_media_enabled = experimental.get( self.msc3916_authenticated_media_enabled = experimental.get(
"msc3916_authenticated_media_enabled", False "msc3916_authenticated_media_enabled", False
) )

View file

@ -642,6 +642,17 @@ class EventCreationHandler:
""" """
await self.auth_blocking.check_auth_blocking(requester=requester) await self.auth_blocking.check_auth_blocking(requester=requester)
if event_dict["type"] == EventTypes.Message:
requester_suspended = await self.store.get_user_suspended_status(
requester.user.to_string()
)
if requester_suspended:
raise SynapseError(
403,
"Sending messages while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)
if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "": if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "":
room_version_id = event_dict["content"]["room_version"] room_version_id = event_dict["content"]["room_version"]
maybe_room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version_id) maybe_room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version_id)

View file

@ -101,6 +101,7 @@ from synapse.rest.admin.users import (
ResetPasswordRestServlet, ResetPasswordRestServlet,
SearchUsersRestServlet, SearchUsersRestServlet,
ShadowBanRestServlet, ShadowBanRestServlet,
SuspendAccountRestServlet,
UserAdminServlet, UserAdminServlet,
UserByExternalId, UserByExternalId,
UserByThreePid, UserByThreePid,
@ -327,6 +328,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
BackgroundUpdateRestServlet(hs).register(http_server) BackgroundUpdateRestServlet(hs).register(http_server)
BackgroundUpdateStartJobRestServlet(hs).register(http_server) BackgroundUpdateStartJobRestServlet(hs).register(http_server)
ExperimentalFeaturesRestServlet(hs).register(http_server) ExperimentalFeaturesRestServlet(hs).register(http_server)
if hs.config.experimental.msc3823_account_suspension:
SuspendAccountRestServlet(hs).register(http_server)
def register_servlets_for_client_rest_resource( def register_servlets_for_client_rest_resource(

View file

@ -27,11 +27,13 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
import attr import attr
from synapse._pydantic_compat import HAS_PYDANTIC_V2
from synapse.api.constants import Direction, UserTypes from synapse.api.constants import Direction, UserTypes
from synapse.api.errors import Codes, NotFoundError, SynapseError from synapse.api.errors import Codes, NotFoundError, SynapseError
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
assert_params_in_dict, assert_params_in_dict,
parse_and_validate_json_object_from_request,
parse_boolean, parse_boolean,
parse_enum, parse_enum,
parse_integer, parse_integer,
@ -49,10 +51,17 @@ from synapse.rest.client._base import client_patterns
from synapse.storage.databases.main.registration import ExternalIDReuseException from synapse.storage.databases.main.registration import ExternalIDReuseException
from synapse.storage.databases.main.stats import UserSortOrder from synapse.storage.databases.main.stats import UserSortOrder
from synapse.types import JsonDict, JsonMapping, UserID from synapse.types import JsonDict, JsonMapping, UserID
from synapse.types.rest import RequestBodyModel
if TYPE_CHECKING: if TYPE_CHECKING:
from synapse.server import HomeServer from synapse.server import HomeServer
if TYPE_CHECKING or HAS_PYDANTIC_V2:
from pydantic.v1 import StrictBool
else:
from pydantic import StrictBool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -732,6 +741,36 @@ class DeactivateAccountRestServlet(RestServlet):
return HTTPStatus.OK, {"id_server_unbind_result": id_server_unbind_result} return HTTPStatus.OK, {"id_server_unbind_result": id_server_unbind_result}
class SuspendAccountRestServlet(RestServlet):
PATTERNS = admin_patterns("/suspend/(?P<target_user_id>[^/]*)$")
def __init__(self, hs: "HomeServer"):
self.auth = hs.get_auth()
self.is_mine = hs.is_mine
self.store = hs.get_datastores().main
class PutBody(RequestBodyModel):
suspend: StrictBool
async def on_PUT(
self, request: SynapseRequest, target_user_id: str
) -> Tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request)
await assert_user_is_admin(self.auth, requester)
if not self.is_mine(UserID.from_string(target_user_id)):
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only suspend local users")
if not await self.store.get_user_by_id(target_user_id):
raise NotFoundError("User not found")
body = parse_and_validate_json_object_from_request(request, self.PutBody)
suspend = body.suspend
await self.store.set_user_suspended_status(target_user_id, suspend)
return HTTPStatus.OK, {f"user_{target_user_id}_suspended": suspend}
class AccountValidityRenewServlet(RestServlet): class AccountValidityRenewServlet(RestServlet):
PATTERNS = admin_patterns("/account_validity/validity$") PATTERNS = admin_patterns("/account_validity/validity$")

View file

@ -108,6 +108,19 @@ class ProfileDisplaynameRestServlet(RestServlet):
propagate = _read_propagate(self.hs, request) propagate = _read_propagate(self.hs, request)
requester_suspended = (
await self.hs.get_datastores().main.get_user_suspended_status(
requester.user.to_string()
)
)
if requester_suspended:
raise SynapseError(
403,
"Updating displayname while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)
await self.profile_handler.set_displayname( await self.profile_handler.set_displayname(
user, requester, new_name, is_admin, propagate=propagate user, requester, new_name, is_admin, propagate=propagate
) )
@ -167,6 +180,19 @@ class ProfileAvatarURLRestServlet(RestServlet):
propagate = _read_propagate(self.hs, request) propagate = _read_propagate(self.hs, request)
requester_suspended = (
await self.hs.get_datastores().main.get_user_suspended_status(
requester.user.to_string()
)
)
if requester_suspended:
raise SynapseError(
403,
"Updating avatar URL while account is suspended is not allowed.",
Codes.USER_ACCOUNT_SUSPENDED,
)
await self.profile_handler.set_avatar_url( await self.profile_handler.set_avatar_url(
user, requester, new_avatar_url, is_admin, propagate=propagate user, requester, new_avatar_url, is_admin, propagate=propagate
) )

View file

@ -1120,6 +1120,20 @@ class RoomRedactEventRestServlet(TransactionRestServlet):
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
content = parse_json_object_from_request(request) content = parse_json_object_from_request(request)
requester_suspended = await self._store.get_user_suspended_status(
requester.user.to_string()
)
if requester_suspended:
event = await self._store.get_event(event_id, allow_none=True)
if event:
if event.sender != requester.user.to_string():
raise SynapseError(
403,
"You can only redact your own events while account is suspended.",
Codes.USER_ACCOUNT_SUSPENDED,
)
# Ensure the redacts property in the content matches the one provided in # Ensure the redacts property in the content matches the one provided in
# the URL. # the URL.
room_version = await self._store.get_room_version(room_id) room_version = await self._store.get_room_version(room_id)

View file

@ -37,6 +37,7 @@ from synapse.api.constants import ApprovalNoticeMedium, LoginType, UserTypes
from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError
from synapse.api.room_versions import RoomVersions from synapse.api.room_versions import RoomVersions
from synapse.media.filepath import MediaFilePaths from synapse.media.filepath import MediaFilePaths
from synapse.rest import admin
from synapse.rest.client import ( from synapse.rest.client import (
devices, devices,
login, login,
@ -5005,3 +5006,86 @@ class AllowCrossSigningReplacementTestCase(unittest.HomeserverTestCase):
) )
assert timestamp is not None assert timestamp is not None
self.assertGreater(timestamp, self.clock.time_msec()) self.assertGreater(timestamp, self.clock.time_msec())
class UserSuspensionTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
admin.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.admin = self.register_user("thomas", "hackme", True)
self.admin_tok = self.login("thomas", "hackme")
self.bad_user = self.register_user("teresa", "hackme")
self.bad_user_tok = self.login("teresa", "hackme")
self.store = hs.get_datastores().main
@override_config({"experimental_features": {"msc3823_account_suspension": True}})
def test_suspend_user(self) -> None:
# test that suspending user works
channel = self.make_request(
"PUT",
f"/_synapse/admin/v1/suspend/{self.bad_user}",
{"suspend": True},
access_token=self.admin_tok,
)
self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body, {f"user_{self.bad_user}_suspended": True})
res = self.get_success(self.store.get_user_suspended_status(self.bad_user))
self.assertEqual(True, res)
# test that un-suspending user works
channel2 = self.make_request(
"PUT",
f"/_synapse/admin/v1/suspend/{self.bad_user}",
{"suspend": False},
access_token=self.admin_tok,
)
self.assertEqual(channel2.code, 200)
self.assertEqual(channel2.json_body, {f"user_{self.bad_user}_suspended": False})
res2 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
self.assertEqual(False, res2)
# test that trying to un-suspend user who isn't suspended doesn't cause problems
channel3 = self.make_request(
"PUT",
f"/_synapse/admin/v1/suspend/{self.bad_user}",
{"suspend": False},
access_token=self.admin_tok,
)
self.assertEqual(channel3.code, 200)
self.assertEqual(channel3.json_body, {f"user_{self.bad_user}_suspended": False})
res3 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
self.assertEqual(False, res3)
# test that trying to suspend user who is already suspended doesn't cause problems
channel4 = self.make_request(
"PUT",
f"/_synapse/admin/v1/suspend/{self.bad_user}",
{"suspend": True},
access_token=self.admin_tok,
)
self.assertEqual(channel4.code, 200)
self.assertEqual(channel4.json_body, {f"user_{self.bad_user}_suspended": True})
res4 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
self.assertEqual(True, res4)
channel5 = self.make_request(
"PUT",
f"/_synapse/admin/v1/suspend/{self.bad_user}",
{"suspend": True},
access_token=self.admin_tok,
)
self.assertEqual(channel5.code, 200)
self.assertEqual(channel5.json_body, {f"user_{self.bad_user}_suspended": True})
res5 = self.get_success(self.store.get_user_suspended_status(self.bad_user))
self.assertEqual(True, res5)

View file

@ -3819,3 +3819,108 @@ class TimestampLookupTestCase(unittest.HomeserverTestCase):
# Make sure the outlier event is not returned # Make sure the outlier event is not returned
self.assertNotEqual(channel.json_body["event_id"], outlier_event.event_id) self.assertNotEqual(channel.json_body["event_id"], outlier_event.event_id)
class UserSuspensionTests(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
profile.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.user1 = self.register_user("thomas", "hackme")
self.tok1 = self.login("thomas", "hackme")
self.user2 = self.register_user("teresa", "hackme")
self.tok2 = self.login("teresa", "hackme")
self.room1 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
self.store = hs.get_datastores().main
def test_suspended_user_cannot_send_message_to_room(self) -> None:
# set the user as suspended
self.get_success(self.store.set_user_suspended_status(self.user1, True))
channel = self.make_request(
"PUT",
f"/rooms/{self.room1}/send/m.room.message/1",
access_token=self.tok1,
content={"body": "hello", "msgtype": "m.text"},
)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)
def test_suspended_user_cannot_change_profile_data(self) -> None:
# set the user as suspended
self.get_success(self.store.set_user_suspended_status(self.user1, True))
channel = self.make_request(
"PUT",
f"/_matrix/client/v3/profile/{self.user1}/avatar_url",
access_token=self.tok1,
content={"avatar_url": "mxc://matrix.org/wefh34uihSDRGhw34"},
shorthand=False,
)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)
channel2 = self.make_request(
"PUT",
f"/_matrix/client/v3/profile/{self.user1}/displayname",
access_token=self.tok1,
content={"displayname": "something offensive"},
shorthand=False,
)
self.assertEqual(
channel2.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)
def test_suspended_user_cannot_redact_messages_other_than_their_own(self) -> None:
# first user sends message
self.make_request("POST", f"/rooms/{self.room1}/join", access_token=self.tok2)
res = self.helper.send_event(
self.room1,
"m.room.message",
{"body": "hello", "msgtype": "m.text"},
tok=self.tok2,
)
event_id = res["event_id"]
# second user sends message
self.make_request("POST", f"/rooms/{self.room1}/join", access_token=self.tok1)
res2 = self.helper.send_event(
self.room1,
"m.room.message",
{"body": "bad_message", "msgtype": "m.text"},
tok=self.tok1,
)
event_id2 = res2["event_id"]
# set the second user as suspended
self.get_success(self.store.set_user_suspended_status(self.user1, True))
# second user can't redact first user's message
channel = self.make_request(
"PUT",
f"/_matrix/client/v3/rooms/{self.room1}/redact/{event_id}/1",
access_token=self.tok1,
content={"reason": "bogus"},
shorthand=False,
)
self.assertEqual(
channel.json_body["errcode"], "ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED"
)
# but can redact their own
channel = self.make_request(
"PUT",
f"/_matrix/client/v3/rooms/{self.room1}/redact/{event_id2}/1",
access_token=self.tok1,
content={"reason": "bogus"},
shorthand=False,
)
self.assertEqual(channel.code, 200)