-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add 3pid unbind callback to module API #13227
Changes from all commits
0274a7f
97f991e
9b4c0e7
5ff0ba2
4ccade6
6073c0e
4dc7b44
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add a module callback for unbinding a 3PID. |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -265,6 +265,33 @@ server_. | |||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
If multiple modules implement this callback, Synapse runs them all in order. | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
### `unbind_threepid` | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
_First introduced in Synapse v1.74.0_ | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
```python | ||||||||||||||||||||||||||||||
async def unbind_threepid( | ||||||||||||||||||||||||||||||
user_id: str, medium: str, address: str, identity_server: str | ||||||||||||||||||||||||||||||
) -> Tuple[bool, bool]: | ||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
Called before a threepid association is removed. | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
The module is given the Matrix ID of the user to which an association is to be removed, | ||||||||||||||||||||||||||||||
as well as the medium (`email` or `msisdn`), address of the third-party identifier and | ||||||||||||||||||||||||||||||
the identity server where the threepid was successfully registered. | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
A module can hence do its own custom unbinding, if for example it did also registered a custom | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
binding logic with `on_threepid_bind`. | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
It should return a tuple of 2 booleans: | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The main thing here is replacing "should" by "must": "should" implies the module is strongly encouraged to do something but there isn't a strict obligation. "must" implies there's a strict obligation, rather than a strong encouragement (which is the case here - Synapse won't be able to process any other type of return value). |
||||||||||||||||||||||||||||||
- first one should be `True` on a success calling the identity server, otherwise `False` if | ||||||||||||||||||||||||||||||
the identity server doesn't support unbinding (or no identity server found to contact). | ||||||||||||||||||||||||||||||
- second one should be `True` if unbind needs to stop there. In this case no other module | ||||||||||||||||||||||||||||||
unbind will be called, and the default unbind made to the IS that was used on bind will also be | ||||||||||||||||||||||||||||||
skipped. In any case the mapping will be removed from the Synapse 3pid remote table, | ||||||||||||||||||||||||||||||
except if an Exception was raised at some point. | ||||||||||||||||||||||||||||||
Comment on lines
+288
to
+293
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
We should also add to the last paragraph a mention of how the first boolean is processed once we have resolved this question in a previous thread. A few things this suggestion changes:
|
||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
## Example | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
The example below is a module that implements the third-party rules callback | ||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -45,6 +45,7 @@ | |||||||||||||||
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] | ||||||||||||||||
UNBIND_THREEPID_CALLBACK = Callable[[str, str, str, str], Awaitable[Tuple[bool, bool]]] | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
def load_legacy_third_party_event_rules(hs: "HomeServer") -> None: | ||||||||||||||||
|
@@ -174,6 +175,7 @@ def __init__(self, hs: "HomeServer"): | |||||||||||||||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK | ||||||||||||||||
] = [] | ||||||||||||||||
self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = [] | ||||||||||||||||
self._unbind_threepid_callbacks: List[UNBIND_THREEPID_CALLBACK] = [] | ||||||||||||||||
|
||||||||||||||||
def register_third_party_rules_callbacks( | ||||||||||||||||
self, | ||||||||||||||||
|
@@ -193,6 +195,7 @@ def register_third_party_rules_callbacks( | |||||||||||||||
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK | ||||||||||||||||
] = None, | ||||||||||||||||
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, | ||||||||||||||||
unbind_threepid: Optional[UNBIND_THREEPID_CALLBACK] = None, | ||||||||||||||||
) -> None: | ||||||||||||||||
"""Register callbacks from modules for each hook.""" | ||||||||||||||||
if check_event_allowed is not None: | ||||||||||||||||
|
@@ -230,6 +233,9 @@ def register_third_party_rules_callbacks( | |||||||||||||||
if on_threepid_bind is not None: | ||||||||||||||||
self._on_threepid_bind_callbacks.append(on_threepid_bind) | ||||||||||||||||
|
||||||||||||||||
if unbind_threepid is not None: | ||||||||||||||||
self._unbind_threepid_callbacks.append(unbind_threepid) | ||||||||||||||||
|
||||||||||||||||
async def check_event_allowed( | ||||||||||||||||
self, event: EventBase, context: EventContext | ||||||||||||||||
) -> Tuple[bool, Optional[dict]]: | ||||||||||||||||
|
@@ -523,3 +529,41 @@ async def on_threepid_bind(self, user_id: str, medium: str, address: str) -> Non | |||||||||||||||
logger.exception( | ||||||||||||||||
"Failed to run module API callback %s: %s", callback, e | ||||||||||||||||
) | ||||||||||||||||
|
||||||||||||||||
async def unbind_threepid( | ||||||||||||||||
self, user_id: str, medium: str, address: str, identity_server: str | ||||||||||||||||
) -> Tuple[bool, bool]: | ||||||||||||||||
"""Called before a threepid association is removed. | ||||||||||||||||
|
||||||||||||||||
Note that this callback is called before an association is deleted on the | ||||||||||||||||
local homeserver. | ||||||||||||||||
Comment on lines
+538
to
+539
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels a bit superfluous with the line above? |
||||||||||||||||
|
||||||||||||||||
Args: | ||||||||||||||||
user_id: the user being associated with the threepid. | ||||||||||||||||
medium: the threepid's medium. | ||||||||||||||||
address: the threepid's address. | ||||||||||||||||
identity_server: the identity server where the threepid was successfully registered. | ||||||||||||||||
|
||||||||||||||||
Returns: | ||||||||||||||||
A tuple of 2 booleans reporting if a changed happened for the first, and if unbind | ||||||||||||||||
needs to stop there for the second (True value). In this case no other module unbind will be | ||||||||||||||||
called, and the default unbind made to the IS that was used on bind will also be skipped. | ||||||||||||||||
In any case the mapping will be removed from the Synapse 3pid remote table, except if an Exception | ||||||||||||||||
was raised at some point. | ||||||||||||||||
Comment on lines
+548
to
+552
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The bit about what happens if the second bool is |
||||||||||||||||
""" | ||||||||||||||||
|
||||||||||||||||
global_changed = False | ||||||||||||||||
for callback in self._unbind_threepid_callbacks: | ||||||||||||||||
try: | ||||||||||||||||
(changed, stop) = await callback( | ||||||||||||||||
user_id, medium, address, identity_server | ||||||||||||||||
) | ||||||||||||||||
global_changed |= changed | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we rather indicate whether all callbacks succeeded, rather than just one of them? |
||||||||||||||||
if stop: | ||||||||||||||||
return global_changed, True | ||||||||||||||||
except Exception as e: | ||||||||||||||||
logger.exception( | ||||||||||||||||
"Failed to run module API callback %s: %s", callback, e | ||||||||||||||||
) | ||||||||||||||||
|
||||||||||||||||
return global_changed, False |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -275,49 +275,64 @@ async def try_unbind_threepid_with_id_server( | |||||
server doesn't support unbinding | ||||||
""" | ||||||
|
||||||
if not valid_id_server_location(id_server): | ||||||
raise SynapseError( | ||||||
400, | ||||||
"id_server must be a valid hostname with optional port and path components", | ||||||
) | ||||||
medium = threepid["medium"] | ||||||
address = threepid["address"] | ||||||
|
||||||
url = "https://%s/_matrix/identity/v2/3pid/unbind" % (id_server,) | ||||||
url_bytes = b"/_matrix/identity/v2/3pid/unbind" | ||||||
|
||||||
content = { | ||||||
"mxid": mxid, | ||||||
"threepid": {"medium": threepid["medium"], "address": threepid["address"]}, | ||||||
} | ||||||
|
||||||
# we abuse the federation http client to sign the request, but we have to send it | ||||||
# using the normal http client since we don't want the SRV lookup and want normal | ||||||
# 'browser-like' HTTPS. | ||||||
auth_headers = self.federation_http_client.build_auth_headers( | ||||||
destination=None, | ||||||
method=b"POST", | ||||||
url_bytes=url_bytes, | ||||||
content=content, | ||||||
destination_is=id_server.encode("ascii"), | ||||||
(changed, stop,) = await self.hs.get_third_party_event_rules().unbind_threepid( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
mxid, medium, address, id_server | ||||||
) | ||||||
headers = {b"Authorization": auth_headers} | ||||||
|
||||||
try: | ||||||
# Use the blacklisting http client as this call is only to identity servers | ||||||
# provided by a client | ||||||
await self.blacklisting_http_client.post_json_get_json( | ||||||
url, content, headers | ||||||
# If a module wants to take over unbind it will return stop = True, | ||||||
# in this case we should just purge the table from the 3pid record | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
if not stop: | ||||||
if not valid_id_server_location(id_server): | ||||||
raise SynapseError( | ||||||
400, | ||||||
"id_server must be a valid hostname with optional port and path components", | ||||||
) | ||||||
|
||||||
url = "https://%s/_matrix/identity/v2/3pid/unbind" % (id_server,) | ||||||
url_bytes = b"/_matrix/identity/v2/3pid/unbind" | ||||||
|
||||||
content = { | ||||||
"mxid": mxid, | ||||||
"threepid": { | ||||||
"medium": threepid["medium"], | ||||||
"address": threepid["address"], | ||||||
}, | ||||||
} | ||||||
|
||||||
# we abuse the federation http client to sign the request, but we have to send it | ||||||
# using the normal http client since we don't want the SRV lookup and want normal | ||||||
# 'browser-like' HTTPS. | ||||||
auth_headers = self.federation_http_client.build_auth_headers( | ||||||
destination=None, | ||||||
method=b"POST", | ||||||
url_bytes=url_bytes, | ||||||
content=content, | ||||||
destination_is=id_server.encode("ascii"), | ||||||
) | ||||||
changed = True | ||||||
except HttpResponseException as e: | ||||||
changed = False | ||||||
if e.code in (400, 404, 501): | ||||||
# The remote server probably doesn't support unbinding (yet) | ||||||
logger.warning("Received %d response while unbinding threepid", e.code) | ||||||
else: | ||||||
logger.error("Failed to unbind threepid on identity server: %s", e) | ||||||
raise SynapseError(500, "Failed to contact identity server") | ||||||
except RequestTimedOutError: | ||||||
raise SynapseError(500, "Timed out contacting identity server") | ||||||
headers = {b"Authorization": auth_headers} | ||||||
|
||||||
try: | ||||||
# Use the blacklisting http client as this call is only to identity servers | ||||||
# provided by a client | ||||||
await self.blacklisting_http_client.post_json_get_json( | ||||||
url, content, headers | ||||||
) | ||||||
changed &= True | ||||||
except HttpResponseException as e: | ||||||
changed &= False | ||||||
if e.code in (400, 404, 501): | ||||||
# The remote server probably doesn't support unbinding (yet) | ||||||
logger.warning( | ||||||
"Received %d response while unbinding threepid", e.code | ||||||
) | ||||||
else: | ||||||
logger.error("Failed to unbind threepid on identity server: %s", e) | ||||||
raise SynapseError(500, "Failed to contact identity server") | ||||||
except RequestTimedOutError: | ||||||
raise SynapseError(500, "Timed out contacting identity server") | ||||||
|
||||||
await self.store.remove_user_bound_threepid( | ||||||
user_id=mxid, | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ | |
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union | ||
from unittest.mock import Mock | ||
|
||
from twisted.internet import defer | ||
from twisted.test.proto_helpers import MemoryReactor | ||
|
||
from synapse.api.constants import EventTypes, LoginType, Membership | ||
|
@@ -931,3 +932,62 @@ def test_on_threepid_bind(self) -> None: | |
|
||
# Check that the mock was called with the right parameters | ||
self.assertEqual(args, (user_id, "email", "[email protected]")) | ||
|
||
def test_unbind_threepid(self) -> None: | ||
"""Tests that the unbind_threepid module callback is called correctly before | ||
removing a 3PID mapping. | ||
""" | ||
|
||
# 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": "[email protected]", | ||
}, | ||
], | ||
}, | ||
access_token=admin_tok, | ||
) | ||
self.assertEqual(channel.code, 200, channel.json_body) | ||
|
||
# Add the mapping to the remote 3pid assoc table | ||
defer.ensureDeferred( | ||
self.hs.get_module_api().store_remote_3pid_association( | ||
user_id, "email", "[email protected]", "identityserver.org" | ||
) | ||
) | ||
|
||
# Register a mocked callback with stop = True since we don't want to actually | ||
# call identityserver.org | ||
threepid_unbind_mock = Mock(return_value=make_awaitable((True, True))) | ||
third_party_rules = self.hs.get_third_party_event_rules() | ||
third_party_rules._unbind_threepid_callbacks.append(threepid_unbind_mock) | ||
|
||
# Deactivate the account, this should remove the 3pid mapping | ||
# and call the module handler. | ||
channel = self.make_request( | ||
"POST", | ||
"/_synapse/admin/v1/deactivate/%s" % user_id, | ||
access_token=admin_tok, | ||
) | ||
self.assertEqual(channel.code, 200, channel.json_body) | ||
|
||
# Check that the mock was called once. | ||
threepid_unbind_mock.assert_called_once() | ||
args = threepid_unbind_mock.call_args[0] | ||
|
||
# Check that the mock was called with the right parameters | ||
self.assertEqual( | ||
args, (user_id, "email", "[email protected]", "identityserver.org") | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't expect users who aren't deeply familiar with Matrix's concepts to know what a "threepid" is.