Add module callbacks called for reacting to deactivation status change and profile update (#12062)

This commit is contained in:
Brendan Abolivier 2022-03-01 15:00:03 +00:00 committed by GitHub
parent f26e390a40
commit 300ed0b8a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 360 additions and 7 deletions

View File

@ -0,0 +1 @@
Add module callbacks to react to user deactivation status changes (i.e. deactivations and reactivations) and profile updates.

View File

@ -148,6 +148,62 @@ deny an incoming event, see [`check_event_for_spam`](spam_checker_callbacks.md#c
If multiple modules implement this callback, Synapse runs them all in order. If multiple modules implement this callback, Synapse runs them all in order.
### `on_profile_update`
_First introduced in Synapse v1.54.0_
```python
async def on_profile_update(
user_id: str,
new_profile: "synapse.module_api.ProfileInfo",
by_admin: bool,
deactivation: bool,
) -> None:
```
Called after updating a local user's profile. The update can be triggered either by the
user themselves or a server admin. The update can also be triggered by a user being
deactivated (in which case their display name is set to an empty string (`""`) and the
avatar URL is set to `None`). The module is passed the Matrix ID of the user whose profile
has been updated, their new profile, as well as a `by_admin` boolean that is `True` if the
update was triggered by a server admin (and `False` otherwise), and a `deactivated`
boolean that is `True` if the update is a result of the user being deactivated.
Note that the `by_admin` boolean is also `True` if the profile change happens as a result
of the user logging in through Single Sign-On, or if a server admin updates their own
profile.
Per-room profile changes do not trigger this callback to be called. Synapse administrators
wishing this callback to be called on every profile change are encouraged to disable
per-room profiles globally using the `allow_per_room_profiles` configuration setting in
Synapse's configuration file.
This callback is not called when registering a user, even when setting it through the
[`get_displayname_for_registration`](https://matrix-org.github.io/synapse/latest/modules/password_auth_provider_callbacks.html#get_displayname_for_registration)
module callback.
If multiple modules implement this callback, Synapse runs them all in order.
### `on_user_deactivation_status_changed`
_First introduced in Synapse v1.54.0_
```python
async def on_user_deactivation_status_changed(
user_id: str, deactivated: bool, by_admin: bool
) -> None:
```
Called after deactivating a local user, or reactivating them through the admin API. The
deactivation can be triggered either by the user themselves or a server admin. The module
is passed the Matrix ID of the user whose status is changed, as well as a `deactivated`
boolean that is `True` if the user is being deactivated and `False` if they're being
reactivated, and a `by_admin` boolean that is `True` if the deactivation was triggered by
a server admin (and `False` otherwise). This latter `by_admin` boolean is always `True`
if the user is being reactivated, as this operation can only be performed through the
admin API.
If multiple modules implement this callback, Synapse runs them all in order.
## Example ## Example
The example below is a module that implements the third-party rules callback The example below is a module that implements the third-party rules callback

View File

@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, Tupl
from synapse.api.errors import ModuleFailedException, SynapseError from synapse.api.errors import ModuleFailedException, SynapseError
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.snapshot import EventContext from synapse.events.snapshot import EventContext
from synapse.storage.roommember import ProfileInfo
from synapse.types import Requester, StateMap from synapse.types import Requester, StateMap
from synapse.util.async_helpers import maybe_awaitable from synapse.util.async_helpers import maybe_awaitable
@ -37,6 +38,8 @@ CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK = Callable[
[str, StateMap[EventBase], str], Awaitable[bool] [str, StateMap[EventBase], str], Awaitable[bool]
] ]
ON_NEW_EVENT_CALLBACK = Callable[[EventBase, StateMap[EventBase]], Awaitable] ON_NEW_EVENT_CALLBACK = Callable[[EventBase, StateMap[EventBase]], Awaitable]
ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool, bool], Awaitable]
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable]
def load_legacy_third_party_event_rules(hs: "HomeServer") -> None: def load_legacy_third_party_event_rules(hs: "HomeServer") -> None:
@ -154,6 +157,10 @@ class ThirdPartyEventRules:
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = [] ] = []
self._on_new_event_callbacks: List[ON_NEW_EVENT_CALLBACK] = [] self._on_new_event_callbacks: List[ON_NEW_EVENT_CALLBACK] = []
self._on_profile_update_callbacks: List[ON_PROFILE_UPDATE_CALLBACK] = []
self._on_user_deactivation_status_changed_callbacks: List[
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = []
def register_third_party_rules_callbacks( def register_third_party_rules_callbacks(
self, self,
@ -166,6 +173,8 @@ class ThirdPartyEventRules:
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = None, ] = None,
on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None, on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None,
on_profile_update: Optional[ON_PROFILE_UPDATE_CALLBACK] = None,
on_deactivation: Optional[ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK] = None,
) -> None: ) -> None:
"""Register callbacks from modules for each hook.""" """Register callbacks from modules for each hook."""
if check_event_allowed is not None: if check_event_allowed is not None:
@ -187,6 +196,12 @@ class ThirdPartyEventRules:
if on_new_event is not None: if on_new_event is not None:
self._on_new_event_callbacks.append(on_new_event) self._on_new_event_callbacks.append(on_new_event)
if on_profile_update is not None:
self._on_profile_update_callbacks.append(on_profile_update)
if on_deactivation is not None:
self._on_user_deactivation_status_changed_callbacks.append(on_deactivation)
async def check_event_allowed( async def check_event_allowed(
self, event: EventBase, context: EventContext self, event: EventBase, context: EventContext
) -> Tuple[bool, Optional[dict]]: ) -> Tuple[bool, Optional[dict]]:
@ -334,9 +349,6 @@ class ThirdPartyEventRules:
Args: Args:
event_id: The ID of the event. event_id: The ID of the event.
Raises:
ModuleFailureError if a callback raised any exception.
""" """
# Bail out early without hitting the store if we don't have any callbacks # Bail out early without hitting the store if we don't have any callbacks
if len(self._on_new_event_callbacks) == 0: if len(self._on_new_event_callbacks) == 0:
@ -370,3 +382,41 @@ class ThirdPartyEventRules:
state_events[key] = room_state_events[event_id] state_events[key] = room_state_events[event_id]
return state_events return state_events
async def on_profile_update(
self, user_id: str, new_profile: ProfileInfo, by_admin: bool, deactivation: bool
) -> None:
"""Called after the global profile of a user has been updated. Does not include
per-room profile changes.
Args:
user_id: The user whose profile was changed.
new_profile: The updated profile for the user.
by_admin: Whether the profile update was performed by a server admin.
deactivation: Whether this change was made while deactivating the user.
"""
for callback in self._on_profile_update_callbacks:
try:
await callback(user_id, new_profile, by_admin, deactivation)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)
async def on_user_deactivation_status_changed(
self, user_id: str, deactivated: bool, by_admin: bool
) -> None:
"""Called after a user has been deactivated or reactivated.
Args:
user_id: The deactivated user.
deactivated: Whether the user is now deactivated.
by_admin: Whether the deactivation was performed by a server admin.
"""
for callback in self._on_user_deactivation_status_changed_callbacks:
try:
await callback(user_id, deactivated, by_admin)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)

View File

@ -38,6 +38,7 @@ class DeactivateAccountHandler:
self._profile_handler = hs.get_profile_handler() self._profile_handler = hs.get_profile_handler()
self.user_directory_handler = hs.get_user_directory_handler() self.user_directory_handler = hs.get_user_directory_handler()
self._server_name = hs.hostname self._server_name = hs.hostname
self._third_party_rules = hs.get_third_party_event_rules()
# Flag that indicates whether the process to part users from rooms is running # Flag that indicates whether the process to part users from rooms is running
self._user_parter_running = False self._user_parter_running = False
@ -135,9 +136,13 @@ class DeactivateAccountHandler:
if erase_data: if erase_data:
user = UserID.from_string(user_id) user = UserID.from_string(user_id)
# Remove avatar URL from this user # Remove avatar URL from this user
await self._profile_handler.set_avatar_url(user, requester, "", by_admin) await self._profile_handler.set_avatar_url(
user, requester, "", by_admin, deactivation=True
)
# Remove displayname from this user # Remove displayname from this user
await self._profile_handler.set_displayname(user, requester, "", by_admin) await self._profile_handler.set_displayname(
user, requester, "", by_admin, deactivation=True
)
logger.info("Marking %s as erased", user_id) logger.info("Marking %s as erased", user_id)
await self.store.mark_user_erased(user_id) await self.store.mark_user_erased(user_id)
@ -160,6 +165,13 @@ class DeactivateAccountHandler:
# Remove account data (including ignored users and push rules). # Remove account data (including ignored users and push rules).
await self.store.purge_account_data_for_user(user_id) await self.store.purge_account_data_for_user(user_id)
# Let modules know the user has been deactivated.
await self._third_party_rules.on_user_deactivation_status_changed(
user_id,
True,
by_admin,
)
return identity_server_supports_unbinding return identity_server_supports_unbinding
async def _reject_pending_invites_for_user(self, user_id: str) -> None: async def _reject_pending_invites_for_user(self, user_id: str) -> None:
@ -264,6 +276,10 @@ class DeactivateAccountHandler:
# Mark the user as active. # Mark the user as active.
await self.store.set_user_deactivated_status(user_id, False) await self.store.set_user_deactivated_status(user_id, False)
await self._third_party_rules.on_user_deactivation_status_changed(
user_id, False, True
)
# Add the user to the directory, if necessary. Note that # Add the user to the directory, if necessary. Note that
# this must be done after the user is re-activated, because # this must be done after the user is re-activated, because
# deactivated users are excluded from the user directory. # deactivated users are excluded from the user directory.

View File

@ -71,6 +71,8 @@ class ProfileHandler:
self.server_name = hs.config.server.server_name self.server_name = hs.config.server.server_name
self._third_party_rules = hs.get_third_party_event_rules()
if hs.config.worker.run_background_tasks: if hs.config.worker.run_background_tasks:
self.clock.looping_call( self.clock.looping_call(
self._update_remote_profile_cache, self.PROFILE_UPDATE_MS self._update_remote_profile_cache, self.PROFILE_UPDATE_MS
@ -171,6 +173,7 @@ class ProfileHandler:
requester: Requester, requester: Requester,
new_displayname: str, new_displayname: str,
by_admin: bool = False, by_admin: bool = False,
deactivation: bool = False,
) -> None: ) -> None:
"""Set the displayname of a user """Set the displayname of a user
@ -179,6 +182,7 @@ class ProfileHandler:
requester: The user attempting to make this change. requester: The user attempting to make this change.
new_displayname: The displayname to give this user. new_displayname: The displayname to give this user.
by_admin: Whether this change was made by an administrator. by_admin: Whether this change was made by an administrator.
deactivation: Whether this change was made while deactivating the user.
""" """
if not self.hs.is_mine(target_user): if not self.hs.is_mine(target_user):
raise SynapseError(400, "User is not hosted on this homeserver") raise SynapseError(400, "User is not hosted on this homeserver")
@ -227,6 +231,10 @@ class ProfileHandler:
target_user.to_string(), profile target_user.to_string(), profile
) )
await self._third_party_rules.on_profile_update(
target_user.to_string(), profile, by_admin, deactivation
)
await self._update_join_states(requester, target_user) await self._update_join_states(requester, target_user)
async def get_avatar_url(self, target_user: UserID) -> Optional[str]: async def get_avatar_url(self, target_user: UserID) -> Optional[str]:
@ -261,6 +269,7 @@ class ProfileHandler:
requester: Requester, requester: Requester,
new_avatar_url: str, new_avatar_url: str,
by_admin: bool = False, by_admin: bool = False,
deactivation: bool = False,
) -> None: ) -> None:
"""Set a new avatar URL for a user. """Set a new avatar URL for a user.
@ -269,6 +278,7 @@ class ProfileHandler:
requester: The user attempting to make this change. requester: The user attempting to make this change.
new_avatar_url: The avatar URL to give this user. new_avatar_url: The avatar URL to give this user.
by_admin: Whether this change was made by an administrator. by_admin: Whether this change was made by an administrator.
deactivation: Whether this change was made while deactivating the user.
""" """
if not self.hs.is_mine(target_user): if not self.hs.is_mine(target_user):
raise SynapseError(400, "User is not hosted on this homeserver") raise SynapseError(400, "User is not hosted on this homeserver")
@ -315,6 +325,10 @@ class ProfileHandler:
target_user.to_string(), profile target_user.to_string(), profile
) )
await self._third_party_rules.on_profile_update(
target_user.to_string(), profile, by_admin, deactivation
)
await self._update_join_states(requester, target_user) await self._update_join_states(requester, target_user)
@cached() @cached()

View File

@ -145,6 +145,7 @@ __all__ = [
"JsonDict", "JsonDict",
"EventBase", "EventBase",
"StateMap", "StateMap",
"ProfileInfo",
] ]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -15,12 +15,12 @@ import threading
from typing import TYPE_CHECKING, Dict, Optional, Tuple from typing import TYPE_CHECKING, Dict, Optional, Tuple
from unittest.mock import Mock from unittest.mock import Mock
from synapse.api.constants import EventTypes, Membership from synapse.api.constants import EventTypes, LoginType, Membership
from synapse.api.errors import SynapseError from synapse.api.errors import SynapseError
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.third_party_rules import load_legacy_third_party_event_rules from synapse.events.third_party_rules import load_legacy_third_party_event_rules
from synapse.rest import admin from synapse.rest import admin
from synapse.rest.client import login, room from synapse.rest.client import account, login, profile, room
from synapse.types import JsonDict, Requester, StateMap from synapse.types import JsonDict, Requester, StateMap
from synapse.util.frozenutils import unfreeze from synapse.util.frozenutils import unfreeze
@ -80,6 +80,8 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase):
admin.register_servlets, admin.register_servlets,
login.register_servlets, login.register_servlets,
room.register_servlets, room.register_servlets,
profile.register_servlets,
account.register_servlets,
] ]
def make_homeserver(self, reactor, clock): def make_homeserver(self, reactor, clock):
@ -530,3 +532,216 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase):
}, },
tok=self.tok, tok=self.tok,
) )
def test_on_profile_update(self):
"""Tests that the on_profile_update module callback is correctly called on
profile updates.
"""
displayname = "Foo"
avatar_url = "mxc://matrix.org/oWQDvfewxmlRaRCkVbfetyEo"
# Register a mock callback.
m = Mock(return_value=make_awaitable(None))
self.hs.get_third_party_event_rules()._on_profile_update_callbacks.append(m)
# Change the display name.
channel = self.make_request(
"PUT",
"/_matrix/client/v3/profile/%s/displayname" % self.user_id,
{"displayname": displayname},
access_token=self.tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the callback has been called once for our user.
m.assert_called_once()
args = m.call_args[0]
self.assertEqual(args[0], self.user_id)
# Test that by_admin is False.
self.assertFalse(args[2])
# Test that deactivation is False.
self.assertFalse(args[3])
# Check that we've got the right profile data.
profile_info = args[1]
self.assertEqual(profile_info.display_name, displayname)
self.assertIsNone(profile_info.avatar_url)
# Change the avatar.
channel = self.make_request(
"PUT",
"/_matrix/client/v3/profile/%s/avatar_url" % self.user_id,
{"avatar_url": avatar_url},
access_token=self.tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the callback has been called once for our user.
self.assertEqual(m.call_count, 2)
args = m.call_args[0]
self.assertEqual(args[0], self.user_id)
# Test that by_admin is False.
self.assertFalse(args[2])
# Test that deactivation is False.
self.assertFalse(args[3])
# Check that we've got the right profile data.
profile_info = args[1]
self.assertEqual(profile_info.display_name, displayname)
self.assertEqual(profile_info.avatar_url, avatar_url)
def test_on_profile_update_admin(self):
"""Tests that the on_profile_update module callback is correctly called on
profile updates triggered by a server admin.
"""
displayname = "Foo"
avatar_url = "mxc://matrix.org/oWQDvfewxmlRaRCkVbfetyEo"
# Register a mock callback.
m = Mock(return_value=make_awaitable(None))
self.hs.get_third_party_event_rules()._on_profile_update_callbacks.append(m)
# Register an admin user.
self.register_user("admin", "password", admin=True)
admin_tok = self.login("admin", "password")
# Change a user's profile.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % self.user_id,
{"displayname": displayname, "avatar_url": avatar_url},
access_token=admin_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the callback has been called twice (since we update the display name
# and avatar separately).
self.assertEqual(m.call_count, 2)
# Get the arguments for the last call and check it's about the right user.
args = m.call_args[0]
self.assertEqual(args[0], self.user_id)
# Check that by_admin is True.
self.assertTrue(args[2])
# Test that deactivation is False.
self.assertFalse(args[3])
# Check that we've got the right profile data.
profile_info = args[1]
self.assertEqual(profile_info.display_name, displayname)
self.assertEqual(profile_info.avatar_url, avatar_url)
def test_on_user_deactivation_status_changed(self):
"""Tests that the on_user_deactivation_status_changed module callback is called
correctly when processing a user's deactivation.
"""
# Register a mocked callback.
deactivation_mock = Mock(return_value=make_awaitable(None))
third_party_rules = self.hs.get_third_party_event_rules()
third_party_rules._on_user_deactivation_status_changed_callbacks.append(
deactivation_mock,
)
# Also register a mocked callback for profile updates, to check that the
# deactivation code calls it in a way that let modules know the user is being
# deactivated.
profile_mock = Mock(return_value=make_awaitable(None))
self.hs.get_third_party_event_rules()._on_profile_update_callbacks.append(
profile_mock,
)
# Register a user that we'll deactivate.
user_id = self.register_user("altan", "password")
tok = self.login("altan", "password")
# Deactivate that user.
channel = self.make_request(
"POST",
"/_matrix/client/v3/account/deactivate",
{
"auth": {
"type": LoginType.PASSWORD,
"password": "password",
"identifier": {
"type": "m.id.user",
"user": user_id,
},
},
"erase": True,
},
access_token=tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the mock was called once.
deactivation_mock.assert_called_once()
args = deactivation_mock.call_args[0]
# Check that the mock was called with the right user ID, and with a True
# deactivated flag and a False by_admin flag.
self.assertEqual(args[0], user_id)
self.assertTrue(args[1])
self.assertFalse(args[2])
# Check that the profile update callback was called twice (once for the display
# name and once for the avatar URL), and that the "deactivation" boolean is true.
self.assertEqual(profile_mock.call_count, 2)
args = profile_mock.call_args[0]
self.assertTrue(args[3])
def test_on_user_deactivation_status_changed_admin(self):
"""Tests that the on_user_deactivation_status_changed module callback is called
correctly when processing a user's deactivation triggered by a server admin as
well as a reactivation.
"""
# Register a mock callback.
m = Mock(return_value=make_awaitable(None))
third_party_rules = self.hs.get_third_party_event_rules()
third_party_rules._on_user_deactivation_status_changed_callbacks.append(m)
# Register an admin user.
self.register_user("admin", "password", admin=True)
admin_tok = self.login("admin", "password")
# Register a user that we'll deactivate.
user_id = self.register_user("altan", "password")
# Deactivate the user.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{"deactivated": True},
access_token=admin_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the mock was called once.
m.assert_called_once()
args = m.call_args[0]
# Check that the mock was called with the right user ID, and with True deactivated
# and by_admin flags.
self.assertEqual(args[0], user_id)
self.assertTrue(args[1])
self.assertTrue(args[2])
# Reactivate the user.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{"deactivated": False, "password": "hackme"},
access_token=admin_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Check that the mock was called once.
self.assertEqual(m.call_count, 2)
args = m.call_args[0]
# Check that the mock was called with the right user ID, and with a False
# deactivated flag and a True by_admin flag.
self.assertEqual(args[0], user_id)
self.assertFalse(args[1])
self.assertTrue(args[2])