Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 4a55d26

Browse files
authored
Add an admin API for shadow-banning users. (#9209)
This expands the current shadow-banning feature to be usable via the admin API and adds documentation for it. 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.
1 parent a71be9d commit 4a55d26

File tree

8 files changed

+164
-7
lines changed

8 files changed

+164
-7
lines changed

changelog.d/9209.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an admin API endpoint for shadow-banning users.

docs/admin_api/user_admin_api.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,3 +760,33 @@ The following fields are returned in the JSON response body:
760760
- ``total`` - integer - Number of pushers.
761761

762762
See also `Client-Server API Spec <https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-pushers>`_
763+
764+
Shadow-banning users
765+
====================
766+
767+
Shadow-banning is a useful tool for moderating malicious or egregiously abusive users.
768+
A shadow-banned users receives successful responses to their client-server API requests,
769+
but the events are not propagated into rooms. This can be an effective tool as it
770+
(hopefully) takes longer for the user to realise they are being moderated before
771+
pivoting to another account.
772+
773+
Shadow-banning a user should be used as a tool of last resort and may lead to confusing
774+
or broken behaviour for the client. A shadow-banned user will not receive any
775+
notification and it is generally more appropriate to ban or kick abusive users.
776+
A shadow-banned user will be unable to contact anyone on the server.
777+
778+
The API is::
779+
780+
POST /_synapse/admin/v1/users/<user_id>/shadow_ban
781+
782+
To use it, you will need to authenticate by providing an ``access_token`` for a
783+
server admin: see `README.rst <README.rst>`_.
784+
785+
An empty JSON dict is returned.
786+
787+
**Parameters**
788+
789+
The following parameters should be set in the URL:
790+
791+
- ``user_id`` - The fully qualified MXID: for example, ``@user:server.com``. The user must
792+
be local.

stubs/txredisapi.pyi

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
"""Contains *incomplete* type hints for txredisapi.
1717
"""
18-
1918
from typing import List, Optional, Type, Union
2019

2120
class RedisProtocol:

synapse/rest/admin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
PushersRestServlet,
5252
ResetPasswordRestServlet,
5353
SearchUsersRestServlet,
54+
ShadowBanRestServlet,
5455
UserAdminServlet,
5556
UserMediaRestServlet,
5657
UserMembershipRestServlet,
@@ -230,6 +231,7 @@ def register_servlets(hs, http_server):
230231
EventReportsRestServlet(hs).register(http_server)
231232
PushersRestServlet(hs).register(http_server)
232233
MakeRoomAdminRestServlet(hs).register(http_server)
234+
ShadowBanRestServlet(hs).register(http_server)
233235

234236

235237
def register_servlets_for_client_rest_resource(hs, http_server):

synapse/rest/admin/users.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,3 +890,39 @@ async def on_POST(self, request, user_id):
890890
)
891891

892892
return 200, {"access_token": token}
893+
894+
895+
class ShadowBanRestServlet(RestServlet):
896+
"""An admin API for shadow-banning a user.
897+
898+
A shadow-banned users receives successful responses to their client-server
899+
API requests, but the events are not propagated into rooms.
900+
901+
Shadow-banning a user should be used as a tool of last resort and may lead
902+
to confusing or broken behaviour for the client.
903+
904+
Example:
905+
906+
POST /_synapse/admin/v1/users/@test:example.com/shadow_ban
907+
{}
908+
909+
200 OK
910+
{}
911+
"""
912+
913+
PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/shadow_ban")
914+
915+
def __init__(self, hs: "HomeServer"):
916+
self.hs = hs
917+
self.store = hs.get_datastore()
918+
self.auth = hs.get_auth()
919+
920+
async def on_POST(self, request, user_id):
921+
await assert_requester_is_admin(self.auth, request)
922+
923+
if not self.hs.is_mine_id(user_id):
924+
raise SynapseError(400, "Only local users can be shadow-banned")
925+
926+
await self.store.set_shadow_banned(UserID.from_string(user_id), True)
927+
928+
return 200, {}

synapse/storage/databases/main/registration.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,35 @@ def set_server_admin_txn(txn):
360360

361361
await self.db_pool.runInteraction("set_server_admin", set_server_admin_txn)
362362

363+
async def set_shadow_banned(self, user: UserID, shadow_banned: bool) -> None:
364+
"""Sets whether a user shadow-banned.
365+
366+
Args:
367+
user: user ID of the user to test
368+
shadow_banned: true iff the user is to be shadow-banned, false otherwise.
369+
"""
370+
371+
def set_shadow_banned_txn(txn):
372+
self.db_pool.simple_update_one_txn(
373+
txn,
374+
table="users",
375+
keyvalues={"name": user.to_string()},
376+
updatevalues={"shadow_banned": shadow_banned},
377+
)
378+
# In order for this to apply immediately, clear the cache for this user.
379+
tokens = self.db_pool.simple_select_onecol_txn(
380+
txn,
381+
table="access_tokens",
382+
keyvalues={"user_id": user.to_string()},
383+
retcol="token",
384+
)
385+
for token in tokens:
386+
self._invalidate_cache_and_stream(
387+
txn, self.get_user_by_access_token, (token,)
388+
)
389+
390+
await self.db_pool.runInteraction("set_shadow_banned", set_shadow_banned_txn)
391+
363392
def _query_for_auth(self, txn, token: str) -> Optional[TokenLookupResult]:
364393
sql = """
365394
SELECT users.name as user_id,

tests/rest/admin/test_user.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,3 +2380,67 @@ def test_get_whois_user(self):
23802380
self.assertEqual(200, channel.code, msg=channel.json_body)
23812381
self.assertEqual(self.other_user, channel.json_body["user_id"])
23822382
self.assertIn("devices", channel.json_body)
2383+
2384+
2385+
class ShadowBanRestTestCase(unittest.HomeserverTestCase):
2386+
2387+
servlets = [
2388+
synapse.rest.admin.register_servlets,
2389+
login.register_servlets,
2390+
]
2391+
2392+
def prepare(self, reactor, clock, hs):
2393+
self.store = hs.get_datastore()
2394+
2395+
self.admin_user = self.register_user("admin", "pass", admin=True)
2396+
self.admin_user_tok = self.login("admin", "pass")
2397+
2398+
self.other_user = self.register_user("user", "pass")
2399+
2400+
self.url = "/_synapse/admin/v1/users/%s/shadow_ban" % urllib.parse.quote(
2401+
self.other_user
2402+
)
2403+
2404+
def test_no_auth(self):
2405+
"""
2406+
Try to get information of an user without authentication.
2407+
"""
2408+
channel = self.make_request("POST", self.url)
2409+
self.assertEqual(401, int(channel.result["code"]), msg=channel.result["body"])
2410+
self.assertEqual(Codes.MISSING_TOKEN, channel.json_body["errcode"])
2411+
2412+
def test_requester_is_not_admin(self):
2413+
"""
2414+
If the user is not a server admin, an error is returned.
2415+
"""
2416+
other_user_token = self.login("user", "pass")
2417+
2418+
channel = self.make_request("POST", self.url, access_token=other_user_token)
2419+
self.assertEqual(403, int(channel.result["code"]), msg=channel.result["body"])
2420+
self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
2421+
2422+
def test_user_is_not_local(self):
2423+
"""
2424+
Tests that shadow-banning for a user that is not a local returns a 400
2425+
"""
2426+
url = "/_synapse/admin/v1/whois/@unknown_person:unknown_domain"
2427+
2428+
channel = self.make_request("POST", url, access_token=self.admin_user_tok)
2429+
self.assertEqual(400, channel.code, msg=channel.json_body)
2430+
2431+
def test_success(self):
2432+
"""
2433+
Shadow-banning should succeed for an admin.
2434+
"""
2435+
# The user starts off as not shadow-banned.
2436+
other_user_token = self.login("user", "pass")
2437+
result = self.get_success(self.store.get_user_by_access_token(other_user_token))
2438+
self.assertFalse(result.shadow_banned)
2439+
2440+
channel = self.make_request("POST", self.url, access_token=self.admin_user_tok)
2441+
self.assertEqual(200, channel.code, msg=channel.json_body)
2442+
self.assertEqual({}, channel.json_body)
2443+
2444+
# Ensure the user is shadow-banned (and the cache was cleared).
2445+
result = self.get_success(self.store.get_user_by_access_token(other_user_token))
2446+
self.assertTrue(result.shadow_banned)

tests/rest/client/test_shadow_banned.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from synapse.api.constants import EventTypes
1919
from synapse.rest.client.v1 import directory, login, profile, room
2020
from synapse.rest.client.v2_alpha import room_upgrade_rest_servlet
21+
from synapse.types import UserID
2122

2223
from tests import unittest
2324

@@ -31,12 +32,7 @@ def prepare(self, reactor, clock, homeserver):
3132
self.store = self.hs.get_datastore()
3233

3334
self.get_success(
34-
self.store.db_pool.simple_update(
35-
table="users",
36-
keyvalues={"name": self.banned_user_id},
37-
updatevalues={"shadow_banned": True},
38-
desc="shadow_ban",
39-
)
35+
self.store.set_shadow_banned(UserID.from_string(self.banned_user_id), True)
4036
)
4137

4238
self.other_user_id = self.register_user("otheruser", "pass")

0 commit comments

Comments
 (0)