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

Add 3pid unbind callback to module API #13227

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/13227.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a module callback for unbinding a 3PID.
27 changes: 27 additions & 0 deletions docs/modules/third_party_rules_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Called before a threepid association is removed.
Called before the association between a local user and a third-party identifier
(email address, phone number) is removed.

I wouldn't expect users who aren't deeply familiar with Matrix's concepts to know what a "threepid" is.


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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
A module can hence do its own custom unbinding, if for example it did also registered a custom
A module can hence implement its own custom unbinding, if for example it also implemented a custom

binding logic with `on_threepid_bind`.

It should return a tuple of 2 booleans:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
It should return a tuple of 2 booleans:
The module must return a tuple of 2 booleans, which indicate respectively:

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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
- 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.
- whether the unbind was successful (`True` in this case, `False` otherwise).
- whether Synapse should stop the unbinding process there (`True` in this case, `False` otherwise). In
this case, Synapse will also remove any local record of an association between the local user and the
third-party identifier on a remote identity server.
If multiple modules implement this callback, they will be considered in order. Unless the return value of
a callback indicates to stop the unbinding process, Synapse falls through to the next one. Otherwise,
Synapse will not call any subsequent implementation of this callback.

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:

  • I don't believe it is safe to assume all modules will do their binding/unbinding against an identity server, so it might not make sense to tie the definition of return values to the existence of an identity server.
  • The bit about exceptions don't feel right to me: first, looking at the code it doesn't look correct; second the way it's phrased makes it sound like modules should use exceptions to define whether Synapse should delete the remote record, and using exceptions as a control method is something we actively try to discourage.
  • Also some clarity and consistency on how Synapse handles multiple modules implementing this callback.


## Example

The example below is a module that implements the third-party rules callback
Expand Down
44 changes: 44 additions & 0 deletions synapse/events/third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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.
A tuple of 2 booleans reporting, respectively, if the unbind was successful, and if the unbind process
should stop there.

The bit about what happens if the second bool is True is only relevant to module callbacks, not to the interface.

"""

global_changed = False
for callback in self._unbind_threepid_callbacks:
try:
(changed, stop) = await callback(
user_id, medium, address, identity_server
)
global_changed |= changed
Copy link
Contributor

Choose a reason for hiding this comment

The 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
93 changes: 54 additions & 39 deletions synapse/handlers/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
(changed, stop,) = await self.hs.get_third_party_event_rules().unbind_threepid(
changed, stop = await self.hs.get_third_party_event_rules().unbind_threepid(

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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
# in this case we should just purge the table from the 3pid record
# in this case we should just remove the entry from the 3pid remote records table

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,
Expand Down
3 changes: 3 additions & 0 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
ON_PROFILE_UPDATE_CALLBACK,
ON_THREEPID_BIND_CALLBACK,
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK,
UNBIND_THREEPID_CALLBACK,
)
from synapse.handlers.account_data import ON_ACCOUNT_DATA_UPDATED_CALLBACK
from synapse.handlers.account_validity import (
Expand Down Expand Up @@ -319,6 +320,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:
"""Registers callbacks for third party event rules capabilities.

Expand All @@ -335,6 +337,7 @@ def register_third_party_rules_callbacks(
on_profile_update=on_profile_update,
on_user_deactivation_status_changed=on_user_deactivation_status_changed,
on_threepid_bind=on_threepid_bind,
unbind_threepid=unbind_threepid,
)

def register_presence_router_callbacks(
Expand Down
60 changes: 60 additions & 0 deletions tests/rest/client/test_third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", "foo@example.com"))

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": "foo@example.com",
},
],
},
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", "foo@example.com", "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", "foo@example.com", "identityserver.org")
)