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

Add module callbacks called for reacting to deactivation and profile update #12062

Merged
merged 12 commits into from
Mar 1, 2022
1 change: 1 addition & 0 deletions changelog.d/12062.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add module callbacks to react to user deactivations and profile udpates.
babolivier marked this conversation as resolved.
Show resolved Hide resolved
38 changes: 38 additions & 0 deletions docs/modules/third_party_rules_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@ 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.

### `on_profile_update`
babolivier marked this conversation as resolved.
Show resolved Hide resolved

_First introduced in Synapse v1.5X.0_
babolivier marked this conversation as resolved.
Show resolved Hide resolved

```python
async def on_profile_update(
user_id: str,
new_profile: "synapse.module_api.ProfileInfo",
by_admin: bool,
) -> None:
```

Called after updating a local user's profile. The update can be triggered either by the
babolivier marked this conversation as resolved.
Show resolved Hide resolved
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
babolivier marked this conversation as resolved.
Show resolved Hide resolved
has been updated, their new profile, as well as a boolean that is `True` if the update
was triggered by a server admin (and `False` otherwise). Note that this boolean is also
`True` if the profile change happens as a result of the user logging through Single
Sing-On, and if a server admin updates their own profile.
babolivier marked this conversation as resolved.
Show resolved Hide resolved

If multiple modules implement this callback, Synapse runs them all in order.

### `on_deactivation`

_First introduced in Synapse v1.5X.0_
babolivier marked this conversation as resolved.
Show resolved Hide resolved

```python
async def on_deactivation(user_id: str, by_admin: bool) -> None:
babolivier marked this conversation as resolved.
Show resolved Hide resolved
```

Called after deactivating a local user. The deactivation can be triggered either by the
user themselves or a server admin. The module is passed the Matrix ID of the user that's
been deactivated, as well as a boolean that is `True` if the deactivation was triggered
babolivier marked this conversation as resolved.
Show resolved Hide resolved
by a server admin (and `False` otherwise).

If multiple modules implement this callback, Synapse runs them all in order.

## Example

The example below is a module that implements the third-party rules callback
Expand Down
50 changes: 47 additions & 3 deletions synapse/events/third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from synapse.api.errors import ModuleFailedException, SynapseError
from synapse.events import EventBase
from synapse.events.snapshot import EventContext
from synapse.storage.roommember import ProfileInfo
from synapse.types import Requester, StateMap
from synapse.util.async_helpers import maybe_awaitable

Expand All @@ -37,6 +38,8 @@
[str, StateMap[EventBase], str], Awaitable[bool]
]
ON_NEW_EVENT_CALLBACK = Callable[[EventBase, StateMap[EventBase]], Awaitable]
ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool], Awaitable]
ON_DEACTIVATION_CALLBACK = Callable[[str, bool], Awaitable]


def load_legacy_third_party_event_rules(hs: "HomeServer") -> None:
Expand Down Expand Up @@ -154,6 +157,8 @@ def __init__(self, hs: "HomeServer"):
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = []
self._on_new_event_callbacks: List[ON_NEW_EVENT_CALLBACK] = []
self._on_profile_update_callbacks: List[ON_PROFILE_UPDATE_CALLBACK] = []
self._on_deactivation_callbacks: List[ON_DEACTIVATION_CALLBACK] = []

def register_third_party_rules_callbacks(
self,
Expand All @@ -166,6 +171,8 @@ def register_third_party_rules_callbacks(
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = None,
on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None,
on_profile_update: Optional[ON_PROFILE_UPDATE_CALLBACK] = None,
on_deactivation: Optional[ON_DEACTIVATION_CALLBACK] = None,
) -> None:
"""Register callbacks from modules for each hook."""
if check_event_allowed is not None:
Expand All @@ -187,6 +194,12 @@ def register_third_party_rules_callbacks(
if on_new_event is not None:
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_deactivation_callbacks.append(on_deactivation)

async def check_event_allowed(
self, event: EventBase, context: EventContext
) -> Tuple[bool, Optional[dict]]:
Expand Down Expand Up @@ -334,9 +347,6 @@ async def on_new_event(self, event_id: str) -> None:

Args:
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
if len(self._on_new_event_callbacks) == 0:
Expand Down Expand Up @@ -370,3 +380,37 @@ async def _get_state_map_for_room(self, room_id: str) -> StateMap[EventBase]:
state_events[key] = room_state_events[event_id]

return state_events

async def on_profile_update(
self, user_id: str, new_profile: ProfileInfo, by_admin: 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.
"""
for callback in self._on_profile_update_callbacks:
try:
await callback(user_id, new_profile, by_admin)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)

async def on_deactivation(self, user_id: str, by_admin: bool) -> None:
"""Called after a user has been deactivated.

Args:
user_id: The deactivated user.
by_admin: Whether the deactivation is performed by a server admin.
babolivier marked this conversation as resolved.
Show resolved Hide resolved
"""
for callback in self._on_deactivation_callbacks:
try:
await callback(user_id, by_admin)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)
4 changes: 4 additions & 0 deletions synapse/handlers/deactivate_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def __init__(self, hs: "HomeServer"):
self._profile_handler = hs.get_profile_handler()
self.user_directory_handler = hs.get_user_directory_handler()
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
self._user_parter_running = False
Expand Down Expand Up @@ -160,6 +161,9 @@ async def deactivate_account(
# Remove account data (including ignored users and push rules).
await self.store.purge_account_data_for_user(user_id)

# Let modules know the user has been deactivated.
await self._third_party_rules.on_deactivation(user_id, by_admin)

return identity_server_supports_unbinding

async def _reject_pending_invites_for_user(self, user_id: str) -> None:
Expand Down
10 changes: 10 additions & 0 deletions synapse/handlers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def __init__(self, hs: "HomeServer"):

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:
self.clock.looping_call(
self._update_remote_profile_cache, self.PROFILE_UPDATE_MS
Expand Down Expand Up @@ -227,6 +229,10 @@ async def set_displayname(
target_user.to_string(), profile
)

await self._third_party_rules.on_profile_update(
target_user.to_string(), profile, by_admin
)

await self._update_join_states(requester, target_user)

async def get_avatar_url(self, target_user: UserID) -> Optional[str]:
Expand Down Expand Up @@ -315,6 +321,10 @@ async def set_avatar_url(
target_user.to_string(), profile
)

await self._third_party_rules.on_profile_update(
target_user.to_string(), profile, by_admin
)

await self._update_join_states(requester, target_user)

@cached()
Expand Down
1 change: 1 addition & 0 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"JsonDict",
"EventBase",
"StateMap",
"ProfileInfo",
]

logger = logging.getLogger(__name__)
Expand Down
173 changes: 171 additions & 2 deletions tests/rest/client/test_third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
from typing import TYPE_CHECKING, Dict, Optional, Tuple
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.events import EventBase
from synapse.events.third_party_rules import load_legacy_third_party_event_rules
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.util.frozenutils import unfreeze

Expand Down Expand Up @@ -80,6 +80,8 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase):
admin.register_servlets,
login.register_servlets,
room.register_servlets,
profile.register_servlets,
account.register_servlets,
]

def make_homeserver(self, reactor, clock):
Expand Down Expand Up @@ -530,3 +532,170 @@ def _update_power_levels(self, event_default: int = 0):
},
tok=self.tok,
)

def test_on_profile_update(self):
"""Tests that the on_profile_update module callback is correctly called on
profile update.
babolivier marked this conversation as resolved.
Show resolved Hide resolved
"""
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])

# 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])

# 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 update triggered by a server admin.
babolivier marked this conversation as resolved.
Show resolved Hide resolved
"""
displayname = "Foo"
avatar_url = "mxc://matrix.org/oWQDvfewxmlRaRCkVbfetyEo"
babolivier marked this conversation as resolved.
Show resolved Hide resolved

# 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])

# 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_deactivation(self):
"""Tests that the on_deactivation module callback is called correctly when
processing a user's deactivation.
"""
# Register a mocked callback.
m = Mock(return_value=make_awaitable(None))
self.hs.get_third_party_event_rules()._on_deactivation_callbacks.append(m)

# 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,
},
}
},
access_token=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 a False
# by_admin flag.
self.assertEqual(args[0], user_id)
self.assertFalse(args[1])

def test_on_deactivation_admin(self):
"""Tests that the on_deactivation module callback is called correctly when
processing a user's deactivation triggered by a server admin.
"""
# Register a mock callback.
m = Mock(return_value=make_awaitable(None))
self.hs.get_third_party_event_rules()._on_deactivation_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")

# Change a user's profile.
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 a True
# by_admin flag.
self.assertEqual(args[0], user_id)
self.assertTrue(args[1])