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 3 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.
18 changes: 18 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,24 @@ server_.

If multiple modules implement this callback, Synapse runs them all in order.

### `on_threepid_unbind`
MatMaul marked this conversation as resolved.
Show resolved Hide resolved

_First introduced in Synapse v1.64.0_

```python
async def on_threepid_unbind(
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.


It should return 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.
MatMaul marked this conversation as resolved.
Show resolved Hide resolved

## Example

The example below is a module that implements the third-party rules callback
Expand Down
46 changes: 46 additions & 0 deletions synapse/events/third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
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_THREEPID_UNBIND_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 +177,7 @@ def __init__(self, hs: "HomeServer"):
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = []
self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = []
self._on_threepid_unbind_callbacks: List[ON_THREEPID_UNBIND_CALLBACK] = []

def register_third_party_rules_callbacks(
self,
Expand All @@ -193,6 +197,7 @@ def register_third_party_rules_callbacks(
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = None,
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None,
on_threepid_unbind: Optional[ON_THREEPID_UNBIND_CALLBACK] = None,
) -> None:
"""Register callbacks from modules for each hook."""
if check_event_allowed is not None:
Expand Down Expand Up @@ -230,6 +235,9 @@ def register_third_party_rules_callbacks(
if on_threepid_bind is not None:
self._on_threepid_bind_callbacks.append(on_threepid_bind)

if on_threepid_unbind is not None:
self._on_threepid_unbind_callbacks.append(on_threepid_unbind)

async def check_event_allowed(
self, event: EventBase, context: EventContext
) -> Tuple[bool, Optional[dict]]:
Expand Down Expand Up @@ -523,3 +531,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 on_threepid_unbind(
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._on_threepid_unbind_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
94 changes: 56 additions & 38 deletions synapse/handlers/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,49 +276,67 @@ 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"]

(
changed,
stop,
) = await self.hs.get_third_party_event_rules().on_threepid_unbind(
mxid, medium, address, id_server
)

url = "https://%s/_matrix/identity/v2/3pid/unbind" % (id_server,)
url_bytes = b"/_matrix/identity/v2/3pid/unbind"
# 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",
)

content = {
"mxid": mxid,
"threepid": {"medium": threepid["medium"], "address": threepid["address"]},
}
url = "https://%s/_matrix/identity/v2/3pid/unbind" % (id_server,)
url_bytes = b"/_matrix/identity/v2/3pid/unbind"

# 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"),
)
headers = {b"Authorization": auth_headers}
content = {
"mxid": mxid,
"threepid": {
"medium": threepid["medium"],
"address": threepid["address"],
},
}

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
# 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 @@ -67,6 +67,7 @@
ON_NEW_EVENT_CALLBACK,
ON_PROFILE_UPDATE_CALLBACK,
ON_THREEPID_BIND_CALLBACK,
ON_THREEPID_UNBIND_CALLBACK,
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK,
)
from synapse.handlers.account_data import ON_ACCOUNT_DATA_UPDATED_CALLBACK
Expand Down Expand Up @@ -317,6 +318,7 @@ def register_third_party_rules_callbacks(
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = None,
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None,
on_threepid_unbind: Optional[ON_THREEPID_UNBIND_CALLBACK] = None,
) -> None:
"""Registers callbacks for third party event rules capabilities.

Expand All @@ -333,6 +335,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,
on_threepid_unbind=on_threepid_unbind,
)

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_on_threepid_unbind(self) -> None:
"""Tests that the on_threepid_unbind 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._on_threepid_unbind_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")
)