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

Remove account data (including client config, push rules and ignored users) upon user deactivation. #11621

Merged
merged 24 commits into from
Jan 24, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
68b663d
Remove account data upon user deactivation
reivilibre Dec 21, 2021
13fec0d
Document that account data is removed upon user deactivation
reivilibre Dec 21, 2021
c1fed49
Newsfile
reivilibre Dec 21, 2021
4da869e
Remove account data upon user deactivation
reivilibre Dec 21, 2021
b132bba
Test the removal of account data upon deactivation
reivilibre Dec 29, 2021
ab97003
Clarify and document what is included in account data
reivilibre Jan 4, 2022
5d4c6ca
Clarify why we purge ignored users and push rules
reivilibre Jan 13, 2022
6d7e9ac
Remove needless super call
reivilibre Jan 13, 2022
d73be6c
Also delete room account data
reivilibre Jan 13, 2022
e58ec5d
Ensure the test actually does something
reivilibre Jan 13, 2022
00f9033
Add a test for room account data
reivilibre Jan 13, 2022
2123435
Add a test for push rules
reivilibre Jan 17, 2022
a1a8c68
Add a test for ignored users
reivilibre Jan 17, 2022
3edab88
Update synapse/storage/databases/main/account_data.py
reivilibre Jan 20, 2022
249b05b
Update synapse/storage/databases/main/account_data.py
reivilibre Jan 20, 2022
37dc2e1
Extract account deactivation test helper
reivilibre Jan 20, 2022
81a2863
Antilint
reivilibre Jan 20, 2022
87473f4
Don't invalidate caches just for tests
reivilibre Jan 20, 2022
e13780e
Handle change in arg order for get_global_account_data_by_type_for_user
reivilibre Jan 20, 2022
cbe543b
Merge branch 'develop' into rei/account_data_deactivation
reivilibre Jan 21, 2022
75f1e2f
Invalidate caches properly when purging account data
reivilibre Jan 21, 2022
1b28e2f
Drive-by type annotations
reivilibre Jan 21, 2022
a68607c
Make `get_account_data_for_room` a tree cache (missed one)
reivilibre Jan 21, 2022
97ceb01
Revert "Drive-by type annotations"
reivilibre Jan 21, 2022
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/11621.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remove account data (including client config, push rules and ignored users) upon user deactivation.
6 changes: 5 additions & 1 deletion docs/admin_api/user_admin_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ The following actions are performed when deactivating an user:
- Remove the user from the user directory
- Reject all pending invites
- Remove all account validity information related to the user
- Remove the arbitrary data store known as *account data*. For example, this includes:
- list of ignored users;
- push rules;
- secret storage keys; and
- cross-signing keys.

The following additional actions are performed during deactivation if `erase`
is set to `true`:
Expand All @@ -366,7 +371,6 @@ The following actions are **NOT** performed. The list may be incomplete.
- Remove mappings of SSO IDs
- [Delete media uploaded](#delete-media-uploaded-by-a-user) by user (included avatar images)
- Delete sent and received messages
- Delete E2E cross-signing keys
- Remove the user's creation (registration) timestamp
- [Remove rate limit overrides](#override-ratelimiting-for-users)
- Remove from monthly active users
Expand Down
3 changes: 3 additions & 0 deletions synapse/handlers/deactivate_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ async def deactivate_account(
# Mark the user as deactivated.
await self.store.set_user_deactivated_status(user_id, True)

# Remove account data (including ignored users and push rules).
await self.store.purge_account_data_for_user(user_id)

return identity_server_supports_unbinding

async def _reject_pending_invites_for_user(self, user_id: str) -> None:
Expand Down
73 changes: 71 additions & 2 deletions synapse/storage/databases/main/account_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
LoggingTransaction,
)
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
from synapse.storage.databases.main.push_rule import PushRulesWorkerStore
from synapse.storage.engines import PostgresEngine
from synapse.storage.util.id_generators import (
AbstractStreamIdGenerator,
Expand All @@ -44,7 +45,7 @@
logger = logging.getLogger(__name__)


class AccountDataWorkerStore(CacheInvalidationWorkerStore):
class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore):
def __init__(
self,
database: DatabasePool,
Expand Down Expand Up @@ -179,7 +180,7 @@ async def get_global_account_data_by_type_for_user(
else:
return None

@cached(num_args=2)
@cached(num_args=2, tree=True)
async def get_account_data_for_room(
self, user_id: str, room_id: str
) -> Dict[str, JsonDict]:
Expand Down Expand Up @@ -546,6 +547,74 @@ def _add_account_data_for_user(
for ignored_user_id in previously_ignored_users ^ currently_ignored_users:
self._invalidate_cache_and_stream(txn, self.ignored_by, (ignored_user_id,))

async def purge_account_data_for_user(self, user_id: str) -> None:
"""
Removes the account data for a user.

This is intended to be used upon user deactivation and also removes any
derived information from account data (e.g. push rules and ignored users).

Args:
user_id: The user ID to remove data for.
"""

def purge_account_data_for_user_txn(txn: LoggingTransaction) -> None:
# Purge from the primary account_data tables.
self.db_pool.simple_delete_txn(
txn, table="account_data", keyvalues={"user_id": user_id}
)

self.db_pool.simple_delete_txn(
txn, table="room_account_data", keyvalues={"user_id": user_id}
)

# Purge from ignored_users where this user is the ignorer.
# N.B. We don't purge where this user is the ignoree, because that
# interferes with other users' account data.
# It's also not this user's data to delete!
self.db_pool.simple_delete_txn(
txn, table="ignored_users", keyvalues={"ignorer_user_id": user_id}
)

# Remove the push rules
self.db_pool.simple_delete_txn(
txn, table="push_rules", keyvalues={"user_name": user_id}
)
self.db_pool.simple_delete_txn(
txn, table="push_rules_enable", keyvalues={"user_name": user_id}
)
self.db_pool.simple_delete_txn(
txn, table="push_rules_stream", keyvalues={"user_id": user_id}
)

# Invalidate caches as appropriate
self._invalidate_cache_and_stream(
txn, self.get_account_data_for_room_and_type, (user_id,)
)
self._invalidate_cache_and_stream(
txn, self.get_account_data_for_user, (user_id,)
)
self._invalidate_cache_and_stream(
txn, self.get_global_account_data_by_type_for_user, (user_id,)
)
self._invalidate_cache_and_stream(
txn, self.get_account_data_for_room, (user_id,)
)
self._invalidate_cache_and_stream(
txn, self.get_push_rules_for_user, (user_id,)
)
self._invalidate_cache_and_stream(
txn, self.get_push_rules_enabled_for_user, (user_id,)
)
# This user might be contained in the ignored_by cache for other users,
# so we have to invalidate it all.
self._invalidate_all_cache_and_stream(txn, self.ignored_by)
Comment on lines +609 to +611
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I agree -- the other users still have this user ignored and might e.g. pull stale data with messages from this user. Those cached entries are still valid (and we're not modifying them). The goal is to purge useless cached data from the user being deactivated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignored_by is a confusing name, I suspect that might be tripping you up.

A is in ignored_by(B) iff A ignores B.

Because we're deleting the fact that A ignores B, we have to invalidate ignored_by(B) for all users B that A ignores. (It's easier in practice to invalidate the whole cache, I think — or to leave it alone, but we've said we're trying not to be too lazy with this).

Note: during the deactivation of A, we only delete the 'A ignores B' facts but not 'B ignores A' facts — if there are still stale messages from A, users ignoring A will continue to do so.

Maybe ignored_by should be get_ignorers_of, but even that sounds a little bit clunky


await self.db_pool.runInteraction(
"purge_account_data_for_user_txn",
purge_account_data_for_user_txn,
)


class AccountDataStore(AccountDataWorkerStore):
pass
5 changes: 3 additions & 2 deletions synapse/storage/databases/main/push_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
AbstractStreamIdTracker,
StreamIdGenerator,
)
from synapse.types import JsonDict
from synapse.util import json_encoder
from synapse.util.caches.descriptors import cached, cachedList
from synapse.util.caches.stream_change_cache import StreamChangeCache
Expand Down Expand Up @@ -126,7 +127,7 @@ def get_max_push_rules_stream_id(self):
raise NotImplementedError()

@cached(max_entries=5000)
async def get_push_rules_for_user(self, user_id):
async def get_push_rules_for_user(self, user_id: str) -> List[JsonDict]:
rows = await self.db_pool.simple_select_list(
table="push_rules",
keyvalues={"user_name": user_id},
Expand All @@ -150,7 +151,7 @@ async def get_push_rules_for_user(self, user_id):
return _load_rules(rows, enabled_map, use_new_defaults)

@cached(max_entries=5000)
async def get_push_rules_enabled_for_user(self, user_id) -> Dict[str, bool]:
async def get_push_rules_enabled_for_user(self, user_id: str) -> Dict[str, bool]:
results = await self.db_pool.simple_select_list(
table="push_rules_enable",
keyvalues={"user_name": user_id},
Expand Down
219 changes: 219 additions & 0 deletions tests/handlers/test_deactivate_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Copyright 2021 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from http import HTTPStatus
from typing import Any, Dict

from twisted.test.proto_helpers import MemoryReactor

from synapse.api.constants import AccountDataTypes
from synapse.push.rulekinds import PRIORITY_CLASS_MAP
from synapse.rest import admin
from synapse.rest.client import account, login
from synapse.server import HomeServer
from synapse.util import Clock

from tests.unittest import HomeserverTestCase


class DeactivateAccountTestCase(HomeserverTestCase):
servlets = [
login.register_servlets,
admin.register_servlets,
account.register_servlets,
]

def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self._store = hs.get_datastore()

self.user = self.register_user("user", "pass")
self.token = self.login("user", "pass")

def _deactivate_my_account(self):
"""
Deactivates the account `self.user` using `self.token` and asserts
that it returns a 200 success code.
"""
req = self.get_success(
self.make_request(
"POST",
"account/deactivate",
{
"auth": {
"type": "m.login.password",
"user": self.user,
"password": "pass",
},
"erase": True,
},
access_token=self.token,
)
)
self.assertEqual(req.code, HTTPStatus.OK, req)

def test_global_account_data_deleted_upon_deactivation(self) -> None:
"""
Tests that global account data is removed upon deactivation.
"""
# Add some account data
self.get_success(
self._store.add_account_data_for_user(
self.user,
AccountDataTypes.DIRECT,
{"@someone:remote": ["!somewhere:remote"]},
)
)

# Check that we actually added some.
self.assertIsNotNone(
self.get_success(
self._store.get_global_account_data_by_type_for_user(
self.user, AccountDataTypes.DIRECT
)
),
)

# Request the deactivation of our account
self._deactivate_my_account()

# Check that the account data does not persist.
self.assertIsNone(
self.get_success(
self._store.get_global_account_data_by_type_for_user(
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
self.user, AccountDataTypes.DIRECT
)
),
)

def test_room_account_data_deleted_upon_deactivation(self) -> None:
"""
Tests that room account data is removed upon deactivation.
"""
room_id = "!room:test"

# Add some room account data
self.get_success(
self._store.add_account_data_to_room(
self.user,
room_id,
"m.fully_read",
{"event_id": "$aaaa:test"},
)
)

# Check that we actually added some.
self.assertIsNotNone(
self.get_success(
self._store.get_account_data_for_room_and_type(
self.user, room_id, "m.fully_read"
)
),
)

# Request the deactivation of our account
self._deactivate_my_account()

# Check that the account data does not persist.
self.assertIsNone(
self.get_success(
self._store.get_account_data_for_room_and_type(
self.user, room_id, "m.fully_read"
)
),
)

def _is_custom_rule(self, push_rule: Dict[str, Any]) -> bool:
"""
Default rules start with a dot: such as .m.rule and .im.vector.
This function returns true iff a rule is custom (not default).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iff -> if

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iff means "if and only if", it isn't a typo. (Although I'm not a big fan of including abbreviations like this.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, thanks! TIL!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It comes up somewhat frequently which is why I usually try to spell it out! Sorry I missed that in review!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries. I learned something new, which is always great. Thank you for taking your time to explain it to me, even when this is already closed! <3

"""
return "/." not in push_rule["rule_id"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why didn't you check push_rule["default"]? While this check did find an error in my code, I'm a bit confused why it is written that way. :D

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Was explained to me now, thanks a lot)


def test_push_rules_deleted_upon_account_deactivation(self) -> None:
"""
Push rules are a special case of account data.
They are stored separately but get sent to the client as account data in /sync.
This tests that deactivating a user deletes push rules along with the rest
of their account data.
"""

# Add a push rule
self.get_success(
self._store.add_push_rule(
self.user,
"personal.override.rule1",
PRIORITY_CLASS_MAP["override"],
[],
[],
)
)

# Test the rule exists
push_rules = self.get_success(self._store.get_push_rules_for_user(self.user))
# Filter out default rules; we don't care
push_rules = list(filter(self._is_custom_rule, push_rules))
# Check our rule made it
self.assertEqual(
push_rules,
[
{
"user_name": "@user:test",
"rule_id": "personal.override.rule1",
"priority_class": 5,
"priority": 0,
"conditions": [],
"actions": [],
"default": False,
}
],
push_rules,
)

# Request the deactivation of our account
self._deactivate_my_account()

push_rules = self.get_success(self._store.get_push_rules_for_user(self.user))
# Filter out default rules; we don't care
push_rules = list(filter(self._is_custom_rule, push_rules))
# Check our rule no longer exists
self.assertEqual(push_rules, [], push_rules)

def test_ignored_users_deleted_upon_deactivation(self) -> None:
"""
Ignored users are a special case of account data.
They get denormalised into the `ignored_users` table upon being stored as
account data.
Test that a user's list of ignored users is deleted upon deactivation.
"""

# Add an ignored user
self.get_success(
self._store.add_account_data_for_user(
self.user,
AccountDataTypes.IGNORED_USER_LIST,
{"ignored_users": {"@sheltie:test": {}}},
)
)

# Test the user is ignored
self.assertEqual(
self.get_success(self._store.ignored_by("@sheltie:test")), {self.user}
)

# Request the deactivation of our account
self._deactivate_my_account()

# Test the user is no longer ignored by the user that was deactivated
self.assertEqual(
self.get_success(self._store.ignored_by("@sheltie:test")), set()
)