🐛(webhook) search existing Matrix user before inviting

fix wrong formatting of user id + now searches for existing Matrix
account of the user before inviting them to webhook room.
This commit is contained in:
Marie PUPO JEAMMET
2025-06-26 17:47:30 +02:00
committed by Marie
parent 5327baacbd
commit 3dc11c9b52
7 changed files with 219 additions and 51 deletions

View File

@@ -19,6 +19,10 @@ and this project adheres to
- 🧑‍💻(docker) split frontend to another file #924 - 🧑‍💻(docker) split frontend to another file #924
### Fixed
- 🐛(webhook) handle user on different home server than room server
## [1.17.0] - 2025-06-11 ## [1.17.0] - 2025-06-11
### Added ### Added

View File

@@ -3,6 +3,54 @@
from rest_framework import status from rest_framework import status
# SEARCH
def mock_search_empty():
"""Mock response when no Matrix user has been found through search."""
return {
"message": {"limited": "false", "results": []},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful(user):
"""Mock response when exactly one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful_multiple(user):
"""Mock response when more than one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
{
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
"display_name": f"@{user.name} [Other Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
# JOIN ROOMS # JOIN ROOMS
def mock_join_room_successful(room_id): def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room.""" """Mock response when succesfully joining room. Same response if already in room."""
@@ -39,7 +87,7 @@ def mock_invite_user_already_in_room(user):
return { return {
"message": { "message": {
"errcode": "M_FORBIDDEN", "errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', ':')} is already in the room.", "error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
}, },
"status_code": status.HTTP_403_FORBIDDEN, "status_code": status.HTTP_403_FORBIDDEN,
} }
@@ -56,7 +104,7 @@ def mock_kick_user_forbidden(user):
return { return {
"message": { "message": {
"errcode": "M_FORBIDDEN", "errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', ':')}.", "error": f"You cannot kick user @{user.email.replace('@', '-')}.",
}, },
"status_code": status.HTTP_403_FORBIDDEN, "status_code": status.HTTP_403_FORBIDDEN,
} }

View File

@@ -247,7 +247,7 @@ def test_api_team_accesses_create__with_matrix_webhook():
team = factories.TeamFactory(users=[(user, "owner")]) team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory( webhook = factories.TeamWebhookFactory(
team=team, team=team,
url="https://server.fr/#/room/room_id:home_server.fr", url="https://server.fr/#/room/room_id:room_server.fr",
secret="some-secret-you-should-not-store-on-a-postit", secret="some-secret-you-should-not-store-on-a-postit",
protocol=enums.WebhookProtocolChoices.MATRIX, protocol=enums.WebhookProtocolChoices.MATRIX,
) )
@@ -264,6 +264,11 @@ def test_api_team_accesses_create__with_matrix_webhook():
status=matrix.mock_join_room_successful("room_id")["status_code"], status=matrix.mock_join_room_successful("room_id")["status_code"],
content_type="application/json", content_type="application/json",
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/invite"), re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]), body=str(matrix.mock_invite_successful()["message"]),
@@ -281,22 +286,23 @@ def test_api_team_accesses_create__with_matrix_webhook():
) )
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert len(responses.calls) == 2 assert len(responses.calls) == 3
assert ( assert (
responses.calls[0].request.url responses.calls[0].request.url
== "https://home_server.fr/_matrix/client/v3/rooms/room_id:home_server.fr/join" == "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
) )
# Payload sent to matrix server # Payload sent to matrix server
assert webhook.secret in responses.calls[0].request.headers["Authorization"] assert webhook.secret in responses.calls[0].request.headers["Authorization"]
assert json.loads(responses.calls[1].request.body) == { assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{other_user.email.replace('@', ':')}", "user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People", "reason": f"User added to team {webhook.team} on People",
} }
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists() assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_success(caplog): def test_api_team_accesses_create__multiple_webhooks_success(caplog):
""" """
When the team has multiple webhooks, creating a team access should fire all the expected calls. When the team has multiple webhooks, creating a team access should fire all the expected calls.
@@ -312,7 +318,7 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
) )
webhook_matrix = factories.TeamWebhookFactory( webhook_matrix = factories.TeamWebhookFactory(
team=team, team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/", url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
protocol=enums.WebhookProtocolChoices.MATRIX, protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo", secret="yo",
) )
@@ -322,39 +328,40 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
with responses.RequestsMock() as rsps: # Ensure successful response by scim provider using "responses":
# Ensure successful response by scim provider using "responses": responses.patch(
rsps.add( re.compile(r".*/Groups/.*"),
rsps.PATCH, body="{}",
re.compile(r".*/Groups/.*"), status=status.HTTP_200_OK,
body="{}", content_type="application/json",
status=200, )
content_type="application/json", responses.post(
) re.compile(r".*/join"),
rsps.add( body=str(matrix.mock_join_room_successful("room_id")["message"]),
rsps.POST, status=status.HTTP_200_OK,
re.compile(r".*/join"), content_type="application/json",
body=str(matrix.mock_join_room_successful), )
status=status.HTTP_200_OK, responses.post(
content_type="application/json", re.compile(r".*/search"),
) body=json.dumps(matrix.mock_search_successful(user)["message"]),
rsps.add( status=matrix.mock_search_successful(user)["status_code"],
rsps.POST, )
re.compile(r".*/invite"), responses.post(
body=str(matrix.mock_invite_successful()["message"]), re.compile(r".*/invite"),
status=matrix.mock_invite_successful()["status_code"], body=str(matrix.mock_invite_successful()["message"]),
content_type="application/json", status=matrix.mock_invite_successful()["status_code"],
) content_type="application/json",
)
response = client.post( response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/", f"/api/v1.0/teams/{team.id!s}/accesses/",
{ {
"user": str(other_user.id), "user": str(other_user.id),
"role": role, "role": role,
}, },
format="json", format="json",
) )
assert response.status_code == 201 assert response.status_code == 201
# Logger # Logger
log_messages = [msg.message for msg in caplog.records] log_messages = [msg.message for msg in caplog.records]

View File

@@ -13,11 +13,63 @@ from rest_framework import status
from core import factories from core import factories
from core.enums import WebhookProtocolChoices from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix from core.tests.fixtures import matrix
from core.utils.matrix import MatrixAPIClient
from core.utils.webhooks import webhooks_synchronizer from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
## SEARCH
@responses.activate
def test_matrix_webhook__search_user_unknown(caplog):
"""When searching for a user in the Matrix federation but cannot find any,
we invite a (future ?) user using user's email and room's server."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_empty()["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
@responses.activate
def test_matrix_webhook__search_multiple_ids(caplog):
"""When searching for a user in Matrix federation,
if user directory returns multiple ids, invite the first one."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
## INVITE ## INVITE
@responses.activate @responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog): def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
@@ -38,6 +90,11 @@ def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful), body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/invite"), re.compile(r".*/invite"),
body=str(error["message"]), body=str(error["message"]),
@@ -64,6 +121,11 @@ def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]), body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"], status=matrix.mock_join_room_successful("room_id")["status_code"],
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/invite"), re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]), body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
@@ -101,6 +163,11 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]), body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"], status=matrix.mock_join_room_successful("room_id")["status_code"],
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/invite"), re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]), body=str(matrix.mock_invite_successful()["message"]),
@@ -113,8 +180,8 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
assert webhook.secret in headers["Authorization"] assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API # Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == { assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}", "user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People", "reason": f"User added to team {webhook.team} on People",
} }
@@ -147,6 +214,11 @@ def test_matrix_webhook__override_secret_for_tchap():
body=str(matrix.mock_join_room_successful("room_id")["message"]), body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"], status=matrix.mock_join_room_successful("room_id")["status_code"],
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/invite"), re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]), body=str(matrix.mock_invite_successful()["message"]),
@@ -178,6 +250,11 @@ def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
body=str(matrix.mock_join_room_successful), body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/kick"), re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]), body=str(matrix.mock_kick_user_not_in_room()["message"]),
@@ -205,7 +282,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
user = factories.UserFactory() user = factories.UserFactory()
webhook = factories.TeamWebhookFactory( webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX, protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server", url="https://www.matrix.org/#/room/room_id:room_server",
secret="secret-access-token", secret="secret-access-token",
) )
@@ -214,6 +291,11 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
body=str(matrix.mock_join_room_successful), body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/kick"), re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful), body=str(matrix.mock_kick_successful),
@@ -222,8 +304,8 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user) webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API # Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == { assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}", "user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User removed from team {webhook.team} on People", "reason": f"User removed from team {webhook.team} on People",
} }
@@ -258,6 +340,11 @@ def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful), body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post( responses.post(
re.compile(r".*/kick"), re.compile(r".*/kick"),
body=str(error["message"]), body=str(error["message"]),

View File

@@ -28,7 +28,7 @@ session.mount("https://", adapter)
class MatrixAPIClient: class MatrixAPIClient:
"""A client to interact with Matrix API""" """A client to interact with Matrix API"""
def get_headers(self, webhook): def get_headers(self, webhook):
"""Build header dict from webhook object.""" """Build header dict from webhook object."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -49,12 +49,27 @@ class MatrixAPIClient:
base_url = f"matrix.{base_url}" base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}" return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user): def get_user_id(self, user, webhook):
"""Returns user id from email.""" """Returns user id from email."""
if user.email is None: if user.email is None:
raise ValueError("You must first set an email for the user.") raise ValueError("You must first set an email for the user.")
return f"@{user.email.replace('@', ':')}" if settings.MATRIX_BASE_HOME_SERVER:
home_server = settings.MATRIX_BASE_HOME_SERVER
search = session.post(
f"{home_server}/_matrix/client/v3/user_directory/search",
json={"search_term": f"@{user.email.replace('@', '-')}"},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
results = search.json()["results"]
if len(results) > 0:
return results[0]["user_id"]
# try and invite unknown user using room home server
room_home_server = webhook.url.split(":")[2]
return f"@{user.email.replace('@', '-')}:{room_home_server}"
def join_room(self, webhook): def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step """Accept invitation to the room. As of today, it is a mandatory step
@@ -76,8 +91,11 @@ class MatrixAPIClient:
webhook.url, webhook.url,
) )
return join_response, False return join_response, False
logger.info(
"Succesfully joined room",
)
user_id = self.get_user_id(user) user_id = self.get_user_id(user, webhook)
response = session.post( response = session.post(
f"{self._get_room_url(webhook.url)}/invite", f"{self._get_room_url(webhook.url)}/invite",
json={ json={
@@ -110,7 +128,7 @@ class MatrixAPIClient:
) )
return join_response, False return join_response, False
user_id = self.get_user_id(user) user_id = self.get_user_id(user, webhook)
response = session.post( response = session.post(
f"{self._get_room_url(webhook.url)}/kick", f"{self._get_room_url(webhook.url)}/kick",
json={ json={

View File

@@ -32,7 +32,6 @@ class WebhookClient:
response, webhook_succeeded = self._get_response_and_status( response, webhook_succeeded = self._get_response_and_status(
name, webhook, user name, webhook, user
) )
if response is not None: if response is not None:
extra = {"response": response.content} extra = {"response": response.content}
# pylint: disable=no-member # pylint: disable=no-member

View File

@@ -601,6 +601,11 @@ class Base(Configuration):
environ_name="DNS_PROVISIONING_API_CREDENTIALS", environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None, environ_prefix=None,
) )
MATRIX_BASE_HOME_SERVER = values.Value(
default="https://matrix.agent.dinum.tchap.gouv.fr",
environ_name="MATRIX_BASE_HOME_SERVER",
environ_prefix=None,
)
MATRIX_BOT_ACCESS_TOKEN = values.Value( MATRIX_BOT_ACCESS_TOKEN = values.Value(
default=None, default=None,
environ_name="MATRIX_BOT_ACCESS_TOKEN", environ_name="MATRIX_BOT_ACCESS_TOKEN",