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

Commit

Permalink
Add 3pid unbind callback to module API
Browse files Browse the repository at this point in the history
  • Loading branch information
MatMaul committed Jul 12, 2022
1 parent 1381563 commit 89ef487
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 38 deletions.
1 change: 1 addition & 0 deletions changelog.d/13227.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add 3pid unbind callback to module API.
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`

_First introduced in Synapse v1.63.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.

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.

## Example

The example below is a module that implements the third-party rules callback
Expand Down
47 changes: 47 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,42 @@ 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.
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.
"""

global_changed = True
for callback in self._on_threepid_unbind_callbacks:
try:
(changed, stop) = await callback(
user_id, medium, address, identity_server
)
global_changed &= changed
if stop:
return (global_changed, True)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)
raise 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 @@ -275,49 +275,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/api/v1/3pid/unbind" % (id_server,)
url_bytes = b"/_matrix/identity/api/v1/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
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/api/v1/3pid/unbind" % (id_server,)
url_bytes = b"/_matrix/identity/api/v1/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 @@ -937,3 +938,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")
)

0 comments on commit 89ef487

Please sign in to comment.