Show erasure status when listing users in the Admin API (#14205)

* Show erasure status when listing users in the Admin API

* Use USING when joining erased_users

* Add changelog entry

* Revert "Use USING when joining erased_users"

This reverts commit 30bd2bf106415caadcfdbdd1b234ef2b106cc394.

* Make the erased check work on postgres

* Add a testcase for showing erased user status

* Appease the style linter

* Explicitly convert `erased` to bool to make SQLite consistent with Postgres

This also adds us an easy way in to fix the other accidentally integered columns.

* Move erasure status test to UsersListTestCase

* Include user erased status when fetching user info via the admin API

* Document the erase status in user_admin_api

* Appease the linter and mypy

* Signpost comments in tests

Co-authored-by: Tadeusz Sośnierz <tadeusz@sosnierz.com>
Co-authored-by: David Robertson <david.m.robertson1@gmail.com>
This commit is contained in:
Tadeusz Sośnierz 2022-10-21 14:52:44 +02:00 committed by GitHub
parent fab495a9e1
commit 1433b5d5b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 51 additions and 3 deletions

View File

@ -0,0 +1 @@
Show erasure status when listing users in the Admin API.

View File

@ -37,6 +37,7 @@ It returns a JSON body like the following:
"is_guest": 0, "is_guest": 0,
"admin": 0, "admin": 0,
"deactivated": 0, "deactivated": 0,
"erased": false,
"shadow_banned": 0, "shadow_banned": 0,
"creation_ts": 1560432506, "creation_ts": 1560432506,
"appservice_id": null, "appservice_id": null,
@ -167,6 +168,7 @@ A response body like the following is returned:
"admin": 0, "admin": 0,
"user_type": null, "user_type": null,
"deactivated": 0, "deactivated": 0,
"erased": false,
"shadow_banned": 0, "shadow_banned": 0,
"displayname": "<User One>", "displayname": "<User One>",
"avatar_url": null, "avatar_url": null,
@ -177,6 +179,7 @@ A response body like the following is returned:
"admin": 1, "admin": 1,
"user_type": null, "user_type": null,
"deactivated": 0, "deactivated": 0,
"erased": false,
"shadow_banned": 0, "shadow_banned": 0,
"displayname": "<User Two>", "displayname": "<User Two>",
"avatar_url": "<avatar_url>", "avatar_url": "<avatar_url>",
@ -247,6 +250,7 @@ The following fields are returned in the JSON response body:
- `user_type` - string - Type of the user. Normal users are type `None`. - `user_type` - string - Type of the user. Normal users are type `None`.
This allows user type specific behaviour. There are also types `support` and `bot`. This allows user type specific behaviour. There are also types `support` and `bot`.
- `deactivated` - bool - Status if that user has been marked as deactivated. - `deactivated` - bool - Status if that user has been marked as deactivated.
- `erased` - bool - Status if that user has been marked as erased.
- `shadow_banned` - bool - Status if that user has been marked as shadow banned. - `shadow_banned` - bool - Status if that user has been marked as shadow banned.
- `displayname` - string - The user's display name if they have set one. - `displayname` - string - The user's display name if they have set one.
- `avatar_url` - string - The user's avatar URL if they have set one. - `avatar_url` - string - The user's avatar URL if they have set one.

View File

@ -100,6 +100,7 @@ class AdminHandler:
user_info_dict["avatar_url"] = profile.avatar_url user_info_dict["avatar_url"] = profile.avatar_url
user_info_dict["threepids"] = threepids user_info_dict["threepids"] = threepids
user_info_dict["external_ids"] = external_ids user_info_dict["external_ids"] = external_ids
user_info_dict["erased"] = await self.store.is_user_erased(user.to_string())
return user_info_dict return user_info_dict

View File

@ -201,7 +201,7 @@ class DataStore(
name: Optional[str] = None, name: Optional[str] = None,
guests: bool = True, guests: bool = True,
deactivated: bool = False, deactivated: bool = False,
order_by: str = UserSortOrder.USER_ID.value, order_by: str = UserSortOrder.NAME.value,
direction: str = "f", direction: str = "f",
approved: bool = True, approved: bool = True,
) -> Tuple[List[JsonDict], int]: ) -> Tuple[List[JsonDict], int]:
@ -261,6 +261,7 @@ class DataStore(
sql_base = f""" sql_base = f"""
FROM users as u FROM users as u
LEFT JOIN profiles AS p ON u.name = '@' || p.user_id || ':' || ? LEFT JOIN profiles AS p ON u.name = '@' || p.user_id || ':' || ?
LEFT JOIN erased_users AS eu ON u.name = eu.user_id
{where_clause} {where_clause}
""" """
sql = "SELECT COUNT(*) as total_users " + sql_base sql = "SELECT COUNT(*) as total_users " + sql_base
@ -269,7 +270,8 @@ class DataStore(
sql = f""" sql = f"""
SELECT name, user_type, is_guest, admin, deactivated, shadow_banned, SELECT name, user_type, is_guest, admin, deactivated, shadow_banned,
displayname, avatar_url, creation_ts * 1000 as creation_ts, approved displayname, avatar_url, creation_ts * 1000 as creation_ts, approved,
eu.user_id is not null as erased
{sql_base} {sql_base}
ORDER BY {order_by_column} {order}, u.name ASC ORDER BY {order_by_column} {order}, u.name ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -277,6 +279,13 @@ class DataStore(
args += [limit, start] args += [limit, start]
txn.execute(sql, args) txn.execute(sql, args)
users = self.db_pool.cursor_to_dict(txn) users = self.db_pool.cursor_to_dict(txn)
# some of those boolean values are returned as integers when we're on SQLite
columns_to_boolify = ["erased"]
for user in users:
for column in columns_to_boolify:
user[column] = bool(user[column])
return users, count return users, count
return await self.db_pool.runInteraction( return await self.db_pool.runInteraction(

View File

@ -31,7 +31,7 @@ from synapse.api.room_versions import RoomVersions
from synapse.rest.client import devices, login, logout, profile, register, room, sync from synapse.rest.client import devices, login, logout, profile, register, room, sync
from synapse.rest.media.v1.filepath import MediaFilePaths from synapse.rest.media.v1.filepath import MediaFilePaths
from synapse.server import HomeServer from synapse.server import HomeServer
from synapse.types import JsonDict, UserID from synapse.types import JsonDict, UserID, create_requester
from synapse.util import Clock from synapse.util import Clock
from tests import unittest from tests import unittest
@ -924,6 +924,36 @@ class UsersListTestCase(unittest.HomeserverTestCase):
self.assertEqual(1, len(non_admin_user_ids), non_admin_user_ids) self.assertEqual(1, len(non_admin_user_ids), non_admin_user_ids)
self.assertEqual(not_approved_user, non_admin_user_ids[0]) self.assertEqual(not_approved_user, non_admin_user_ids[0])
def test_erasure_status(self) -> None:
# Create a new user.
user_id = self.register_user("eraseme", "eraseme")
# They should appear in the list users API, marked as not erased.
channel = self.make_request(
"GET",
self.url + "?deactivated=true",
access_token=self.admin_user_tok,
)
users = {user["name"]: user for user in channel.json_body["users"]}
self.assertIs(users[user_id]["erased"], False)
# Deactivate that user, requesting erasure.
deactivate_account_handler = self.hs.get_deactivate_account_handler()
self.get_success(
deactivate_account_handler.deactivate_account(
user_id, erase_data=True, requester=create_requester(user_id)
)
)
# Repeat the list users query. They should now be marked as erased.
channel = self.make_request(
"GET",
self.url + "?deactivated=true",
access_token=self.admin_user_tok,
)
users = {user["name"]: user for user in channel.json_body["users"]}
self.assertIs(users[user_id]["erased"], True)
def _order_test( def _order_test(
self, self,
expected_user_list: List[str], expected_user_list: List[str],
@ -1195,6 +1225,7 @@ class DeactivateAccountTestCase(unittest.HomeserverTestCase):
self.assertEqual("foo@bar.com", channel.json_body["threepids"][0]["address"]) self.assertEqual("foo@bar.com", channel.json_body["threepids"][0]["address"])
self.assertEqual("mxc://servername/mediaid", channel.json_body["avatar_url"]) self.assertEqual("mxc://servername/mediaid", channel.json_body["avatar_url"])
self.assertEqual("User1", channel.json_body["displayname"]) self.assertEqual("User1", channel.json_body["displayname"])
self.assertFalse(channel.json_body["erased"])
# Deactivate and erase user # Deactivate and erase user
channel = self.make_request( channel = self.make_request(
@ -1219,6 +1250,7 @@ class DeactivateAccountTestCase(unittest.HomeserverTestCase):
self.assertEqual(0, len(channel.json_body["threepids"])) self.assertEqual(0, len(channel.json_body["threepids"]))
self.assertIsNone(channel.json_body["avatar_url"]) self.assertIsNone(channel.json_body["avatar_url"])
self.assertIsNone(channel.json_body["displayname"]) self.assertIsNone(channel.json_body["displayname"])
self.assertTrue(channel.json_body["erased"])
self._is_erased("@user:test", True) self._is_erased("@user:test", True)
@ -2757,6 +2789,7 @@ class UserRestTestCase(unittest.HomeserverTestCase):
self.assertIn("avatar_url", content) self.assertIn("avatar_url", content)
self.assertIn("admin", content) self.assertIn("admin", content)
self.assertIn("deactivated", content) self.assertIn("deactivated", content)
self.assertIn("erased", content)
self.assertIn("shadow_banned", content) self.assertIn("shadow_banned", content)
self.assertIn("creation_ts", content) self.assertIn("creation_ts", content)
self.assertIn("appservice_id", content) self.assertIn("appservice_id", content)