Merge branch 'develop' into jaywink/admin-forward-extremities
# Conflicts: # synapse/rest/admin/__init__.pypull/9062/head
commit
cee4010f94
|
@ -0,0 +1 @@
|
||||||
|
Fix spurious errors in logs when deleting a non-existant pusher.
|
|
@ -0,0 +1 @@
|
||||||
|
Add an admin API endpoint for shadow-banning users.
|
|
@ -760,3 +760,33 @@ The following fields are returned in the JSON response body:
|
||||||
- ``total`` - integer - Number of pushers.
|
- ``total`` - integer - Number of pushers.
|
||||||
|
|
||||||
See also `Client-Server API Spec <https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-pushers>`_
|
See also `Client-Server API Spec <https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-pushers>`_
|
||||||
|
|
||||||
|
Shadow-banning users
|
||||||
|
====================
|
||||||
|
|
||||||
|
Shadow-banning is a useful tool for moderating malicious or egregiously abusive users.
|
||||||
|
A shadow-banned users receives successful responses to their client-server API requests,
|
||||||
|
but the events are not propagated into rooms. This can be an effective tool as it
|
||||||
|
(hopefully) takes longer for the user to realise they are being moderated before
|
||||||
|
pivoting to another account.
|
||||||
|
|
||||||
|
Shadow-banning a user should be used as a tool of last resort and may lead to confusing
|
||||||
|
or broken behaviour for the client. A shadow-banned user will not receive any
|
||||||
|
notification and it is generally more appropriate to ban or kick abusive users.
|
||||||
|
A shadow-banned user will be unable to contact anyone on the server.
|
||||||
|
|
||||||
|
The API is::
|
||||||
|
|
||||||
|
POST /_synapse/admin/v1/users/<user_id>/shadow_ban
|
||||||
|
|
||||||
|
To use it, you will need to authenticate by providing an ``access_token`` for a
|
||||||
|
server admin: see `README.rst <README.rst>`_.
|
||||||
|
|
||||||
|
An empty JSON dict is returned.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
The following parameters should be set in the URL:
|
||||||
|
|
||||||
|
- ``user_id`` - The fully qualified MXID: for example, ``@user:server.com``. The user must
|
||||||
|
be local.
|
||||||
|
|
|
@ -15,7 +15,6 @@
|
||||||
|
|
||||||
"""Contains *incomplete* type hints for txredisapi.
|
"""Contains *incomplete* type hints for txredisapi.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Optional, Type, Union
|
from typing import List, Optional, Type, Union
|
||||||
|
|
||||||
class RedisProtocol:
|
class RedisProtocol:
|
||||||
|
|
|
@ -54,6 +54,7 @@ from synapse.rest.admin.users import (
|
||||||
PushersRestServlet,
|
PushersRestServlet,
|
||||||
ResetPasswordRestServlet,
|
ResetPasswordRestServlet,
|
||||||
SearchUsersRestServlet,
|
SearchUsersRestServlet,
|
||||||
|
ShadowBanRestServlet,
|
||||||
UserAdminServlet,
|
UserAdminServlet,
|
||||||
UserMediaRestServlet,
|
UserMediaRestServlet,
|
||||||
UserMembershipRestServlet,
|
UserMembershipRestServlet,
|
||||||
|
@ -233,6 +234,7 @@ def register_servlets(hs, http_server):
|
||||||
EventReportsRestServlet(hs).register(http_server)
|
EventReportsRestServlet(hs).register(http_server)
|
||||||
PushersRestServlet(hs).register(http_server)
|
PushersRestServlet(hs).register(http_server)
|
||||||
MakeRoomAdminRestServlet(hs).register(http_server)
|
MakeRoomAdminRestServlet(hs).register(http_server)
|
||||||
|
ShadowBanRestServlet(hs).register(http_server)
|
||||||
ForwardExtremitiesRestServlet(hs).register(http_server)
|
ForwardExtremitiesRestServlet(hs).register(http_server)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -890,3 +890,39 @@ class UserTokenRestServlet(RestServlet):
|
||||||
)
|
)
|
||||||
|
|
||||||
return 200, {"access_token": token}
|
return 200, {"access_token": token}
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowBanRestServlet(RestServlet):
|
||||||
|
"""An admin API for shadow-banning a user.
|
||||||
|
|
||||||
|
A shadow-banned users receives successful responses to their client-server
|
||||||
|
API requests, but the events are not propagated into rooms.
|
||||||
|
|
||||||
|
Shadow-banning a user should be used as a tool of last resort and may lead
|
||||||
|
to confusing or broken behaviour for the client.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
POST /_synapse/admin/v1/users/@test:example.com/shadow_ban
|
||||||
|
{}
|
||||||
|
|
||||||
|
200 OK
|
||||||
|
{}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/shadow_ban")
|
||||||
|
|
||||||
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
self.hs = hs
|
||||||
|
self.store = hs.get_datastore()
|
||||||
|
self.auth = hs.get_auth()
|
||||||
|
|
||||||
|
async def on_POST(self, request, user_id):
|
||||||
|
await assert_requester_is_admin(self.auth, request)
|
||||||
|
|
||||||
|
if not self.hs.is_mine_id(user_id):
|
||||||
|
raise SynapseError(400, "Only local users can be shadow-banned")
|
||||||
|
|
||||||
|
await self.store.set_shadow_banned(UserID.from_string(user_id), True)
|
||||||
|
|
||||||
|
return 200, {}
|
||||||
|
|
|
@ -344,7 +344,9 @@ class PusherStore(PusherWorkerStore):
|
||||||
txn, self.get_if_user_has_pusher, (user_id,)
|
txn, self.get_if_user_has_pusher, (user_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db_pool.simple_delete_one_txn(
|
# It is expected that there is exactly one pusher to delete, but
|
||||||
|
# if it isn't there (or there are multiple) delete them all.
|
||||||
|
self.db_pool.simple_delete_txn(
|
||||||
txn,
|
txn,
|
||||||
"pushers",
|
"pushers",
|
||||||
{"app_id": app_id, "pushkey": pushkey, "user_name": user_id},
|
{"app_id": app_id, "pushkey": pushkey, "user_name": user_id},
|
||||||
|
|
|
@ -360,6 +360,35 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
||||||
|
|
||||||
await self.db_pool.runInteraction("set_server_admin", set_server_admin_txn)
|
await self.db_pool.runInteraction("set_server_admin", set_server_admin_txn)
|
||||||
|
|
||||||
|
async def set_shadow_banned(self, user: UserID, shadow_banned: bool) -> None:
|
||||||
|
"""Sets whether a user shadow-banned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: user ID of the user to test
|
||||||
|
shadow_banned: true iff the user is to be shadow-banned, false otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set_shadow_banned_txn(txn):
|
||||||
|
self.db_pool.simple_update_one_txn(
|
||||||
|
txn,
|
||||||
|
table="users",
|
||||||
|
keyvalues={"name": user.to_string()},
|
||||||
|
updatevalues={"shadow_banned": shadow_banned},
|
||||||
|
)
|
||||||
|
# In order for this to apply immediately, clear the cache for this user.
|
||||||
|
tokens = self.db_pool.simple_select_onecol_txn(
|
||||||
|
txn,
|
||||||
|
table="access_tokens",
|
||||||
|
keyvalues={"user_id": user.to_string()},
|
||||||
|
retcol="token",
|
||||||
|
)
|
||||||
|
for token in tokens:
|
||||||
|
self._invalidate_cache_and_stream(
|
||||||
|
txn, self.get_user_by_access_token, (token,)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db_pool.runInteraction("set_shadow_banned", set_shadow_banned_txn)
|
||||||
|
|
||||||
def _query_for_auth(self, txn, token: str) -> Optional[TokenLookupResult]:
|
def _query_for_auth(self, txn, token: str) -> Optional[TokenLookupResult]:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT users.name as user_id,
|
SELECT users.name as user_id,
|
||||||
|
|
|
@ -2380,3 +2380,67 @@ class WhoisRestTestCase(unittest.HomeserverTestCase):
|
||||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||||
self.assertEqual(self.other_user, channel.json_body["user_id"])
|
self.assertEqual(self.other_user, channel.json_body["user_id"])
|
||||||
self.assertIn("devices", channel.json_body)
|
self.assertIn("devices", channel.json_body)
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowBanRestTestCase(unittest.HomeserverTestCase):
|
||||||
|
|
||||||
|
servlets = [
|
||||||
|
synapse.rest.admin.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor, clock, hs):
|
||||||
|
self.store = hs.get_datastore()
|
||||||
|
|
||||||
|
self.admin_user = self.register_user("admin", "pass", admin=True)
|
||||||
|
self.admin_user_tok = self.login("admin", "pass")
|
||||||
|
|
||||||
|
self.other_user = self.register_user("user", "pass")
|
||||||
|
|
||||||
|
self.url = "/_synapse/admin/v1/users/%s/shadow_ban" % urllib.parse.quote(
|
||||||
|
self.other_user
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_auth(self):
|
||||||
|
"""
|
||||||
|
Try to get information of an user without authentication.
|
||||||
|
"""
|
||||||
|
channel = self.make_request("POST", self.url)
|
||||||
|
self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"])
|
||||||
|
self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"])
|
||||||
|
|
||||||
|
def test_requester_is_not_admin(self):
|
||||||
|
"""
|
||||||
|
If the user is not a server admin, an error is returned.
|
||||||
|
"""
|
||||||
|
other_user_token = self.login("user", "pass")
|
||||||
|
|
||||||
|
channel = self.make_request("POST", self.url, access_token=other_user_token)
|
||||||
|
self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])
|
||||||
|
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
|
||||||
|
|
||||||
|
def test_user_is_not_local(self):
|
||||||
|
"""
|
||||||
|
Tests that shadow-banning for a user that is not a local returns a 400
|
||||||
|
"""
|
||||||
|
url = "/_synapse/admin/v1/whois/@unknown_person:unknown_domain"
|
||||||
|
|
||||||
|
channel = self.make_request("POST", url, access_token=self.admin_user_tok)
|
||||||
|
self.assertEqual(400, channel.code, msg=channel.json_body)
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
"""
|
||||||
|
Shadow-banning should succeed for an admin.
|
||||||
|
"""
|
||||||
|
# The user starts off as not shadow-banned.
|
||||||
|
other_user_token = self.login("user", "pass")
|
||||||
|
result = self.get_success(self.store.get_user_by_access_token(other_user_token))
|
||||||
|
self.assertFalse(result.shadow_banned)
|
||||||
|
|
||||||
|
channel = self.make_request("POST", self.url, access_token=self.admin_user_tok)
|
||||||
|
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||||
|
self.assertEqual({}, channel.json_body)
|
||||||
|
|
||||||
|
# Ensure the user is shadow-banned (and the cache was cleared).
|
||||||
|
result = self.get_success(self.store.get_user_by_access_token(other_user_token))
|
||||||
|
self.assertTrue(result.shadow_banned)
|
||||||
|
|
|
@ -18,6 +18,7 @@ import synapse.rest.admin
|
||||||
from synapse.api.constants import EventTypes
|
from synapse.api.constants import EventTypes
|
||||||
from synapse.rest.client.v1 import directory, login, profile, room
|
from synapse.rest.client.v1 import directory, login, profile, room
|
||||||
from synapse.rest.client.v2_alpha import room_upgrade_rest_servlet
|
from synapse.rest.client.v2_alpha import room_upgrade_rest_servlet
|
||||||
|
from synapse.types import UserID
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
|
||||||
|
@ -31,12 +32,7 @@ class _ShadowBannedBase(unittest.HomeserverTestCase):
|
||||||
self.store = self.hs.get_datastore()
|
self.store = self.hs.get_datastore()
|
||||||
|
|
||||||
self.get_success(
|
self.get_success(
|
||||||
self.store.db_pool.simple_update(
|
self.store.set_shadow_banned(UserID.from_string(self.banned_user_id), True)
|
||||||
table="users",
|
|
||||||
keyvalues={"name": self.banned_user_id},
|
|
||||||
updatevalues={"shadow_banned": True},
|
|
||||||
desc="shadow_ban",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.other_user_id = self.register_user("otheruser", "pass")
|
self.other_user_id = self.register_user("otheruser", "pass")
|
||||||
|
|
Loading…
Reference in New Issue