diff --git a/changelog.d/12302.feature b/changelog.d/12302.feature new file mode 100644 index 0000000000..603fa2d23a --- /dev/null +++ b/changelog.d/12302.feature @@ -0,0 +1 @@ +Add a module callback to react to new 3PID (email address, phone number) associations. diff --git a/docs/modules/third_party_rules_callbacks.md b/docs/modules/third_party_rules_callbacks.md index 1d3c39967f..e1a5b6524f 100644 --- a/docs/modules/third_party_rules_callbacks.md +++ b/docs/modules/third_party_rules_callbacks.md @@ -247,6 +247,24 @@ admin API. If multiple modules implement this callback, Synapse runs them all in order. +### `on_threepid_bind` + +_First introduced in Synapse v1.56.0_ + +```python +async def on_threepid_bind(user_id: str, medium: str, address: str) -> None: +``` + +Called after creating an association between a local user and a third-party identifier +(email address, phone number). The module is given the Matrix ID of the user the +association is for, as well as the medium (`email` or `msisdn`) and address of the +third-party identifier. + +Note that this callback is _not_ called after a successful association on an _identity +server_. + +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 diff --git a/synapse/events/third_party_rules.py b/synapse/events/third_party_rules.py index bfca454f51..ef68e20282 100644 --- a/synapse/events/third_party_rules.py +++ b/synapse/events/third_party_rules.py @@ -42,6 +42,7 @@ CHECK_CAN_SHUTDOWN_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]] CHECK_CAN_DEACTIVATE_USER_CALLBACK = Callable[[str, bool], Awaitable[bool]] ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool, bool], Awaitable] ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable] +ON_THREEPID_BIND_CALLBACK = Callable[[str, str, str], Awaitable] def load_legacy_third_party_event_rules(hs: "HomeServer") -> None: @@ -169,6 +170,7 @@ class ThirdPartyEventRules: self._on_user_deactivation_status_changed_callbacks: List[ ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ] = [] + self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = [] def register_third_party_rules_callbacks( self, @@ -187,6 +189,7 @@ class ThirdPartyEventRules: on_user_deactivation_status_changed: Optional[ ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ] = None, + on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, ) -> None: """Register callbacks from modules for each hook.""" if check_event_allowed is not None: @@ -221,6 +224,9 @@ class ThirdPartyEventRules: on_user_deactivation_status_changed, ) + if on_threepid_bind is not None: + self._on_threepid_bind_callbacks.append(on_threepid_bind) + async def check_event_allowed( self, event: EventBase, context: EventContext ) -> Tuple[bool, Optional[dict]]: @@ -479,3 +485,23 @@ class ThirdPartyEventRules: logger.exception( "Failed to run module API callback %s: %s", callback, e ) + + async def on_threepid_bind(self, user_id: str, medium: str, address: str) -> None: + """Called after a threepid association has been verified and stored. + + Note that this callback is called when an association is created on the + local homeserver, not when it's created on an identity server (and then kept track + of so that it can be unbound on the same IS later on). + + Args: + user_id: the user being associated with the threepid. + medium: the threepid's medium. + address: the threepid's address. + """ + for callback in self._on_threepid_bind_callbacks: + try: + await callback(user_id, medium, address) + except Exception as e: + logger.exception( + "Failed to run module API callback %s: %s", callback, e + ) diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index 3e29c96a49..86991d26ce 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -211,6 +211,7 @@ class AuthHandler: self.macaroon_gen = hs.get_macaroon_generator() self._password_enabled = hs.config.auth.password_enabled self._password_localdb_enabled = hs.config.auth.password_localdb_enabled + self._third_party_rules = hs.get_third_party_event_rules() # Ratelimiter for failed auth during UIA. Uses same ratelimit config # as per `rc_login.failed_attempts`. @@ -1505,6 +1506,8 @@ class AuthHandler: user_id, medium, address, validated_at, self.hs.get_clock().time_msec() ) + await self._third_party_rules.on_threepid_bind(user_id, medium, address) + async def delete_threepid( self, user_id: str, medium: str, address: str, id_server: Optional[str] = None ) -> bool: diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index ba9755f08b..3c7dcca74d 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -62,6 +62,7 @@ from synapse.events.third_party_rules import ( ON_CREATE_ROOM_CALLBACK, ON_NEW_EVENT_CALLBACK, ON_PROFILE_UPDATE_CALLBACK, + ON_THREEPID_BIND_CALLBACK, ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, ) from synapse.handlers.account_validity import ( @@ -293,6 +294,7 @@ class ModuleApi: on_user_deactivation_status_changed: Optional[ ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ] = None, + on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, ) -> None: """Registers callbacks for third party event rules capabilities. @@ -308,6 +310,7 @@ class ModuleApi: check_can_deactivate_user=check_can_deactivate_user, on_profile_update=on_profile_update, on_user_deactivation_status_changed=on_user_deactivation_status_changed, + on_threepid_bind=on_threepid_bind, ) def register_presence_router_callbacks( diff --git a/tests/rest/client/test_third_party_rules.py b/tests/rest/client/test_third_party_rules.py index e7de67e3a3..5eb0f243f7 100644 --- a/tests/rest/client/test_third_party_rules.py +++ b/tests/rest/client/test_third_party_rules.py @@ -896,3 +896,44 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase): # Check that the mock was called with the right room ID self.assertEqual(args[1], self.room_id) + + def test_on_threepid_bind(self) -> None: + """Tests that the on_threepid_bind module callback is called correctly after + associating a 3PID to an account. + """ + # Register a mocked callback. + threepid_bind_mock = Mock(return_value=make_awaitable(None)) + third_party_rules = self.hs.get_third_party_event_rules() + third_party_rules._on_threepid_bind_callbacks.append(threepid_bind_mock) + + # Register an admin user. + self.register_user("admin", "password", admin=True) + admin_tok = self.login("admin", "password") + + # Also register a normal user we can modify. + user_id = self.register_user("user", "password") + + # Add a 3PID to the user. + channel = self.make_request( + "PUT", + "/_synapse/admin/v2/users/%s" % user_id, + { + "threepids": [ + { + "medium": "email", + "address": "foo@example.com", + }, + ], + }, + access_token=admin_tok, + ) + + # Check that the shutdown was blocked + self.assertEqual(channel.code, 200, channel.json_body) + + # Check that the mock was called once. + threepid_bind_mock.assert_called_once() + args = threepid_bind_mock.call_args[0] + + # Check that the mock was called with the right parameters + self.assertEqual(args, (user_id, "email", "foo@example.com"))