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 deactivation status changes (i.e. deactivations and reactivations) and profile updates.
Copy link
Member

Choose a reason for hiding this comment

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

Would it be worth linking to the documentation for these new methods here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure, we haven't done that in the past.

Copy link
Member

Choose a reason for hiding this comment

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

It might be useful if module developers want to start using the new methods? We don't name them here either.

56 changes: 56 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,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.

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

_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
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 `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

The example below is a module that implements the third-party rules callback
Expand Down
56 changes: 53 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, bool], Awaitable]
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable]


def load_legacy_third_party_event_rules(hs: "HomeServer") -> None:
Expand Down Expand Up @@ -154,6 +157,10 @@ 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_user_deactivation_status_changed_callbacks: List[
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = []

def register_third_party_rules_callbacks(
self,
Expand All @@ -166,6 +173,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_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK] = None,
) -> None:
"""Register callbacks from modules for each hook."""
if check_event_allowed is not None:
Expand All @@ -187,6 +196,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_user_deactivation_status_changed_callbacks.append(on_deactivation)

async def check_event_allowed(
self, event: EventBase, context: EventContext
) -> Tuple[bool, Optional[dict]]:
Expand Down Expand Up @@ -334,9 +349,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 +382,41 @@ 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, 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
)
20 changes: 18 additions & 2 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 @@ -135,9 +136,13 @@ async def deactivate_account(
if erase_data:
user = UserID.from_string(user_id)
# 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
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)
await self.store.mark_user_erased(user_id)
Expand All @@ -160,6 +165,13 @@ 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_user_deactivation_status_changed(
user_id,
True,
by_admin,
)

return identity_server_supports_unbinding

async def _reject_pending_invites_for_user(self, user_id: str) -> None:
Expand Down Expand Up @@ -264,6 +276,10 @@ async def activate_account(self, user_id: str) -> None:
# Mark the user as active.
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
# this must be done after the user is re-activated, because
# deactivated users are excluded from the user directory.
Expand Down
14 changes: 14 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 @@ -171,6 +173,7 @@ async def set_displayname(
requester: Requester,
new_displayname: str,
by_admin: bool = False,
deactivation: bool = False,
) -> None:
"""Set the displayname of a user

Expand All @@ -179,6 +182,7 @@ async def set_displayname(
requester: The user attempting to make this change.
new_displayname: The displayname to give this user.
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):
raise SynapseError(400, "User is not hosted on this homeserver")
Expand Down Expand Up @@ -227,6 +231,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, deactivation
)

await self._update_join_states(requester, target_user)

async def get_avatar_url(self, target_user: UserID) -> Optional[str]:
Expand Down Expand Up @@ -261,6 +269,7 @@ async def set_avatar_url(
requester: Requester,
new_avatar_url: str,
by_admin: bool = False,
deactivation: bool = False,
) -> None:
"""Set a new avatar URL for a user.

Expand All @@ -269,6 +278,7 @@ async def set_avatar_url(
requester: The user attempting to make this change.
new_avatar_url: The avatar URL to give this user.
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):
raise SynapseError(400, "User is not hosted on this homeserver")
Expand Down Expand Up @@ -315,6 +325,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, deactivation
)

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
Loading