@@ -0,0 +1 @@ | |||
Add two new Third Party Rules module API callbacks: [`on_add_user_third_party_identifier`](https://matrix-org.github.io/synapse/v1.79/modules/third_party_rules_callbacks.html#on_add_user_third_party_identifier) and [`on_remove_user_third_party_identifier`](https://matrix-org.github.io/synapse/v1.79/modules/third_party_rules_callbacks.html#on_remove_user_third_party_identifier). |
@@ -254,6 +254,11 @@ If multiple modules implement this callback, Synapse runs them all in order. | |||
_First introduced in Synapse v1.56.0_ | |||
**<span style="color:red"> | |||
This callback is deprecated in favour of the `on_add_user_third_party_identifier` callback, which | |||
features the same functionality. The only difference is in name. | |||
</span>** | |||
```python | |||
async def on_threepid_bind(user_id: str, medium: str, address: str) -> None: | |||
``` | |||
@@ -268,6 +273,44 @@ server_. | |||
If multiple modules implement this callback, Synapse runs them all in order. | |||
### `on_add_user_third_party_identifier` | |||
_First introduced in Synapse v1.79.0_ | |||
```python | |||
async def on_add_user_third_party_identifier(user_id: str, medium: str, address: str) -> None: | |||
``` | |||
Called after successfully creating an association between a 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 (i.e. an email address). | |||
Note that this callback is _not_ called if a user attempts to bind their third-party identifier | |||
to an identity server (via a call to [`POST | |||
/_matrix/client/v3/account/3pid/bind`](https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3account3pidbind)). | |||
If multiple modules implement this callback, Synapse runs them all in order. | |||
### `on_remove_user_third_party_identifier` | |||
_First introduced in Synapse v1.79.0_ | |||
```python | |||
async def on_remove_user_third_party_identifier(user_id: str, medium: str, address: str) -> None: | |||
``` | |||
Called after successfully removing an association between a 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 (i.e. an email address). | |||
Note that this callback is _not_ called if a user attempts to unbind their third-party | |||
identifier from an identity server (via a call to [`POST | |||
/_matrix/client/v3/account/3pid/unbind`](https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3account3pidunbind)). | |||
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 | |||
@@ -300,4 +343,4 @@ class EventCensorer: | |||
) | |||
event_dict["content"] = new_event_content | |||
return event_dict | |||
``` | |||
``` |
@@ -88,6 +88,30 @@ process, for example: | |||
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb | |||
``` | |||
# Upgrading to v1.79.0 | |||
## The `on_threepid_bind` module callback method has been deprecated | |||
Synapse v1.79.0 deprecates the | |||
[`on_threepid_bind`](modules/third_party_rules_callbacks.md#on_threepid_bind) | |||
"third-party rules" Synapse module callback method in favour of a new module method, | |||
[`on_add_user_third_party_identifier`](modules/third_party_rules_callbacks.md#on_add_user_third_party_identifier). | |||
`on_threepid_bind` will be removed in a future version of Synapse. You should check whether any Synapse | |||
modules in use in your deployment are making use of `on_threepid_bind`, and update them where possible. | |||
The arguments and functionality of the new method are the same. | |||
The justification behind the name change is that the old method's name, `on_threepid_bind`, was | |||
misleading. A user is considered to "bind" their third-party ID to their Matrix ID only if they | |||
do so via an [identity server](https://spec.matrix.org/latest/identity-service-api/) | |||
(so that users on other homeservers may find them). But this method was not called in that case - | |||
it was only called when a user added a third-party identifier on the local homeserver. | |||
Module developers may also be interested in the related | |||
[`on_remove_user_third_party_identifier`](modules/third_party_rules_callbacks.md#on_remove_user_third_party_identifier) | |||
module callback method that was also added in Synapse v1.79.0. This new method is called when a | |||
user removes a third-party identifier from their account. | |||
# Upgrading to v1.78.0 | |||
## Deprecate the `/_synapse/admin/v1/media/<server_name>/delete` admin API | |||
@@ -45,6 +45,8 @@ 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] | |||
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK = Callable[[str, str, str], Awaitable] | |||
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK = Callable[[str, str, str], Awaitable] | |||
def load_legacy_third_party_event_rules(hs: "HomeServer") -> None: | |||
@@ -172,6 +174,12 @@ class ThirdPartyEventRules: | |||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK | |||
] = [] | |||
self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = [] | |||
self._on_add_user_third_party_identifier_callbacks: List[ | |||
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = [] | |||
self._on_remove_user_third_party_identifier_callbacks: List[ | |||
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = [] | |||
def register_third_party_rules_callbacks( | |||
self, | |||
@@ -191,6 +199,12 @@ class ThirdPartyEventRules: | |||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK | |||
] = None, | |||
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, | |||
on_add_user_third_party_identifier: Optional[ | |||
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = None, | |||
on_remove_user_third_party_identifier: Optional[ | |||
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = None, | |||
) -> None: | |||
"""Register callbacks from modules for each hook.""" | |||
if check_event_allowed is not None: | |||
@@ -228,6 +242,11 @@ class ThirdPartyEventRules: | |||
if on_threepid_bind is not None: | |||
self._on_threepid_bind_callbacks.append(on_threepid_bind) | |||
if on_add_user_third_party_identifier is not None: | |||
self._on_add_user_third_party_identifier_callbacks.append( | |||
on_add_user_third_party_identifier | |||
) | |||
async def check_event_allowed( | |||
self, | |||
event: EventBase, | |||
@@ -511,6 +530,9 @@ class ThirdPartyEventRules: | |||
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). | |||
THIS MODULE CALLBACK METHOD HAS BEEN DEPRECATED. Please use the | |||
`on_add_user_third_party_identifier` callback method instead. | |||
Args: | |||
user_id: the user being associated with the threepid. | |||
medium: the threepid's medium. | |||
@@ -523,3 +545,44 @@ class ThirdPartyEventRules: | |||
logger.exception( | |||
"Failed to run module API callback %s: %s", callback, e | |||
) | |||
async def on_add_user_third_party_identifier( | |||
self, user_id: str, medium: str, address: str | |||
) -> None: | |||
"""Called when an association between a user's Matrix ID and a third-party ID | |||
(email, phone number) has successfully been registered on the homeserver. | |||
Args: | |||
user_id: The User ID included in the association. | |||
medium: The medium of the third-party ID (email, msisdn). | |||
address: The address of the third-party ID (i.e. an email address). | |||
""" | |||
for callback in self._on_add_user_third_party_identifier_callbacks: | |||
try: | |||
await callback(user_id, medium, address) | |||
except Exception as e: | |||
logger.exception( | |||
"Failed to run module API callback %s: %s", callback, e | |||
) | |||
async def on_remove_user_third_party_identifier( | |||
self, user_id: str, medium: str, address: str | |||
) -> None: | |||
"""Called when an association between a user's Matrix ID and a third-party ID | |||
(email, phone number) has been successfully removed on the homeserver. | |||
This is called *after* any known bindings on identity servers for this | |||
association have been removed. | |||
Args: | |||
user_id: The User ID included in the removed association. | |||
medium: The medium of the third-party ID (email, msisdn). | |||
address: The address of the third-party ID (i.e. an email address). | |||
""" | |||
for callback in self._on_remove_user_third_party_identifier_callbacks: | |||
try: | |||
await callback(user_id, medium, address) | |||
except Exception as e: | |||
logger.exception( | |||
"Failed to run module API callback %s: %s", callback, e | |||
) |
@@ -1542,6 +1542,17 @@ class AuthHandler: | |||
async def add_threepid( | |||
self, user_id: str, medium: str, address: str, validated_at: int | |||
) -> None: | |||
""" | |||
Adds an association between a user's Matrix ID and a third-party ID (email, | |||
phone number). | |||
Args: | |||
user_id: The ID of the user to associate. | |||
medium: The medium of the third-party ID (email, msisdn). | |||
address: The address of the third-party ID (i.e. an email address). | |||
validated_at: The timestamp in ms of when the validation that the user owns | |||
this third-party ID occurred. | |||
""" | |||
# check if medium has a valid value | |||
if medium not in ["email", "msisdn"]: | |||
raise SynapseError( | |||
@@ -1566,42 +1577,44 @@ class AuthHandler: | |||
user_id, medium, address, validated_at, self.hs.get_clock().time_msec() | |||
) | |||
# Inform Synapse modules that a 3PID association has been created. | |||
await self._third_party_rules.on_add_user_third_party_identifier( | |||
user_id, medium, address | |||
) | |||
# Deprecated method for informing Synapse modules that a 3PID association | |||
# has successfully been created. | |||
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: | |||
"""Attempts to unbind the 3pid on the identity servers and deletes it | |||
from the local database. | |||
async def delete_local_threepid( | |||
self, user_id: str, medium: str, address: str | |||
) -> None: | |||
"""Deletes an association between a third-party ID and a user ID from the local | |||
database. This method does not unbind the association from any identity servers. | |||
If `medium` is 'email' and a pusher is associated with this third-party ID, the | |||
pusher will also be deleted. | |||
Args: | |||
user_id: ID of user to remove the 3pid from. | |||
medium: The medium of the 3pid being removed: "email" or "msisdn". | |||
address: The 3pid address to remove. | |||
id_server: Use the given identity server when unbinding | |||
any threepids. If None then will attempt to unbind using the | |||
identity server specified when binding (if known). | |||
Returns: | |||
Returns True if successfully unbound the 3pid on | |||
the identity server, False if identity server doesn't support the | |||
unbind API. | |||
""" | |||
# 'Canonicalise' email addresses as per above | |||
if medium == "email": | |||
address = canonicalise_email(address) | |||
result = await self.hs.get_identity_handler().try_unbind_threepid( | |||
user_id, medium, address, id_server | |||
await self.store.user_delete_threepid(user_id, medium, address) | |||
# Inform Synapse modules that a 3PID association has been deleted. | |||
await self._third_party_rules.on_remove_user_third_party_identifier( | |||
user_id, medium, address | |||
) | |||
await self.store.user_delete_threepid(user_id, medium, address) | |||
if medium == "email": | |||
await self.store.delete_pusher_by_app_id_pushkey_user_id( | |||
app_id="m.email", pushkey=address, user_id=user_id | |||
) | |||
return result | |||
async def hash(self, password: str) -> str: | |||
"""Computes a secure hash of password. | |||
@@ -100,26 +100,28 @@ class DeactivateAccountHandler: | |||
# unbinding | |||
identity_server_supports_unbinding = True | |||
# Retrieve the 3PIDs this user has bound to an identity server | |||
threepids = await self.store.user_get_bound_threepids(user_id) | |||
for threepid in threepids: | |||
# Attempt to unbind any known bound threepids to this account from identity | |||
# server(s). | |||
bound_threepids = await self.store.user_get_bound_threepids(user_id) | |||
for threepid in bound_threepids: | |||
try: | |||
result = await self._identity_handler.try_unbind_threepid( | |||
user_id, threepid["medium"], threepid["address"], id_server | |||
) | |||
identity_server_supports_unbinding &= result | |||
except Exception: | |||
# Do we want this to be a fatal error or should we carry on? | |||
logger.exception("Failed to remove threepid from ID server") | |||
raise SynapseError(400, "Failed to remove threepid from ID server") | |||
await self.store.user_delete_threepid( | |||
identity_server_supports_unbinding &= result | |||
# Remove any local threepid associations for this account. | |||
local_threepids = await self.store.user_get_threepids(user_id) | |||
for threepid in local_threepids: | |||
await self._auth_handler.delete_local_threepid( | |||
user_id, threepid["medium"], threepid["address"] | |||
) | |||
# Remove all 3PIDs this user has bound to the homeserver | |||
await self.store.user_delete_threepids(user_id) | |||
# delete any devices belonging to the user, which will also | |||
# delete corresponding access tokens. | |||
await self._device_handler.delete_all_devices_for_user(user_id) | |||
@@ -64,9 +64,11 @@ from synapse.events.third_party_rules import ( | |||
CHECK_EVENT_ALLOWED_CALLBACK, | |||
CHECK_THREEPID_CAN_BE_INVITED_CALLBACK, | |||
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK, | |||
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK, | |||
ON_CREATE_ROOM_CALLBACK, | |||
ON_NEW_EVENT_CALLBACK, | |||
ON_PROFILE_UPDATE_CALLBACK, | |||
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK, | |||
ON_THREEPID_BIND_CALLBACK, | |||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, | |||
) | |||
@@ -357,6 +359,12 @@ class ModuleApi: | |||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK | |||
] = None, | |||
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, | |||
on_add_user_third_party_identifier: Optional[ | |||
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = None, | |||
on_remove_user_third_party_identifier: Optional[ | |||
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK | |||
] = None, | |||
) -> None: | |||
"""Registers callbacks for third party event rules capabilities. | |||
@@ -373,6 +381,8 @@ class ModuleApi: | |||
on_profile_update=on_profile_update, | |||
on_user_deactivation_status_changed=on_user_deactivation_status_changed, | |||
on_threepid_bind=on_threepid_bind, | |||
on_add_user_third_party_identifier=on_add_user_third_party_identifier, | |||
on_remove_user_third_party_identifier=on_remove_user_third_party_identifier, | |||
) | |||
def register_presence_router_callbacks( | |||
@@ -304,13 +304,20 @@ class UserRestServletV2(RestServlet): | |||
# remove old threepids | |||
for medium, address in del_threepids: | |||
try: | |||
await self.auth_handler.delete_threepid( | |||
user_id, medium, address, None | |||
# Attempt to remove any known bindings of this third-party ID | |||
# and user ID from identity servers. | |||
await self.hs.get_identity_handler().try_unbind_threepid( | |||
user_id, medium, address, id_server=None | |||
) | |||
except Exception: | |||
logger.exception("Failed to remove threepids") | |||
raise SynapseError(500, "Failed to remove threepids") | |||
# Delete the local association of this user ID and third-party ID. | |||
await self.auth_handler.delete_local_threepid( | |||
user_id, medium, address | |||
) | |||
# add new threepids | |||
current_time = self.hs.get_clock().time_msec() | |||
for medium, address in add_threepids: | |||
@@ -768,7 +768,9 @@ class ThreepidDeleteRestServlet(RestServlet): | |||
user_id = requester.user.to_string() | |||
try: | |||
ret = await self.auth_handler.delete_threepid( | |||
# Attempt to remove any known bindings of this third-party ID | |||
# and user ID from identity servers. | |||
ret = await self.hs.get_identity_handler().try_unbind_threepid( | |||
user_id, body.medium, body.address, body.id_server | |||
) | |||
except Exception: | |||
@@ -783,6 +785,11 @@ class ThreepidDeleteRestServlet(RestServlet): | |||
else: | |||
id_server_unbind_result = "no-support" | |||
# Delete the local association of this user ID and third-party ID. | |||
await self.auth_handler.delete_local_threepid( | |||
user_id, body.medium, body.address | |||
) | |||
return 200, {"id_server_unbind_result": id_server_unbind_result} | |||
@@ -1002,19 +1002,6 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
desc="user_delete_threepid", | |||
) | |||
async def user_delete_threepids(self, user_id: str) -> None: | |||
"""Delete all threepid this user has bound | |||
Args: | |||
user_id: The user id to delete all threepids of | |||
""" | |||
await self.db_pool.simple_delete( | |||
"user_threepids", | |||
keyvalues={"user_id": user_id}, | |||
desc="user_delete_threepids", | |||
) | |||
async def add_user_bound_threepid( | |||
self, user_id: str, medium: str, address: str, id_server: str | |||
) -> None: | |||
@@ -369,10 +369,8 @@ class EmailPusherTests(HomeserverTestCase): | |||
# disassociate the user's email address | |||
self.get_success( | |||
self.auth_handler.delete_threepid( | |||
user_id=self.user_id, | |||
medium="email", | |||
address="a@example.com", | |||
self.auth_handler.delete_local_threepid( | |||
user_id=self.user_id, medium="email", address="a@example.com" | |||
) | |||
) | |||
@@ -934,3 +934,124 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase): | |||
# Check that the mock was called with the right parameters | |||
self.assertEqual(args, (user_id, "email", "foo@example.com")) | |||
def test_on_add_and_remove_user_third_party_identifier(self) -> None: | |||
"""Tests that the on_add_user_third_party_identifier and | |||
on_remove_user_third_party_identifier module callbacks are called | |||
just before associating and removing a 3PID to/from an account. | |||
""" | |||
# Pretend to be a Synapse module and register both callbacks as mocks. | |||
third_party_rules = self.hs.get_third_party_event_rules() | |||
on_add_user_third_party_identifier_callback_mock = Mock( | |||
return_value=make_awaitable(None) | |||
) | |||
on_remove_user_third_party_identifier_callback_mock = Mock( | |||
return_value=make_awaitable(None) | |||
) | |||
third_party_rules._on_threepid_bind_callbacks.append( | |||
on_add_user_third_party_identifier_callback_mock | |||
) | |||
third_party_rules._on_threepid_bind_callbacks.append( | |||
on_remove_user_third_party_identifier_callback_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 mocked add callback was called with the appropriate | |||
# 3PID details. | |||
self.assertEqual(channel.code, 200, channel.json_body) | |||
on_add_user_third_party_identifier_callback_mock.assert_called_once() | |||
args = on_add_user_third_party_identifier_callback_mock.call_args[0] | |||
self.assertEqual(args, (user_id, "email", "foo@example.com")) | |||
# Now remove the 3PID from the user | |||
channel = self.make_request( | |||
"PUT", | |||
"/_synapse/admin/v2/users/%s" % user_id, | |||
{ | |||
"threepids": [], | |||
}, | |||
access_token=admin_tok, | |||
) | |||
# Check that the mocked remove callback was called with the appropriate | |||
# 3PID details. | |||
self.assertEqual(channel.code, 200, channel.json_body) | |||
on_remove_user_third_party_identifier_callback_mock.assert_called_once() | |||
args = on_remove_user_third_party_identifier_callback_mock.call_args[0] | |||
self.assertEqual(args, (user_id, "email", "foo@example.com")) | |||
def test_on_remove_user_third_party_identifier_is_called_on_deactivate( | |||
self, | |||
) -> None: | |||
"""Tests that the on_remove_user_third_party_identifier module callback is called | |||
when a user is deactivated and their third-party ID associations are deleted. | |||
""" | |||
# Pretend to be a Synapse module and register both callbacks as mocks. | |||
third_party_rules = self.hs.get_third_party_event_rules() | |||
on_remove_user_third_party_identifier_callback_mock = Mock( | |||
return_value=make_awaitable(None) | |||
) | |||
third_party_rules._on_threepid_bind_callbacks.append( | |||
on_remove_user_third_party_identifier_callback_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, | |||
) | |||
self.assertEqual(channel.code, 200, channel.json_body) | |||
# Now deactivate the user. | |||
channel = self.make_request( | |||
"PUT", | |||
"/_synapse/admin/v2/users/%s" % user_id, | |||
{ | |||
"deactivated": True, | |||
}, | |||
access_token=admin_tok, | |||
) | |||
# Check that the mocked remove callback was called with the appropriate | |||
# 3PID details. | |||
self.assertEqual(channel.code, 200, channel.json_body) | |||
on_remove_user_third_party_identifier_callback_mock.assert_called_once() | |||
args = on_remove_user_third_party_identifier_callback_mock.call_args[0] | |||
self.assertEqual(args, (user_id, "email", "foo@example.com")) |