From cc39ed5298fb8375313baf9089e068e8ff78df2e Mon Sep 17 00:00:00 2001 From: Marie PUPO JEAMMET Date: Fri, 9 May 2025 17:49:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(teams)=20add=20matrix=20webhook=20for?= =?UTF-8?q?=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webhook to invite/kick team members to a matrix room. --- CHANGELOG.md | 1 + src/backend/core/enums.py | 7 + .../migrations/0017_teamwebhook_protocol.py | 18 ++ src/backend/core/models.py | 34 ++- src/backend/core/tests/fixtures/__init__.py | 1 + src/backend/core/tests/fixtures/matrix.py | 75 +++++ .../test_api_team_accesses_create.py | 150 +++++++++- .../utils/test_webhooks_matrix_client.py | 277 ++++++++++++++++++ .../tests/utils/test_webhooks_scim_client.py | 20 +- src/backend/core/utils/matrix.py | 136 +++++++++ src/backend/core/utils/scim.py | 8 +- src/backend/core/utils/webhooks.py | 66 +++-- src/backend/people/settings.py | 5 + src/helm/env.d/dev/values.desk.yaml.gotmpl | 1 + 14 files changed, 743 insertions(+), 56 deletions(-) create mode 100644 src/backend/core/migrations/0017_teamwebhook_protocol.py create mode 100644 src/backend/core/tests/fixtures/__init__.py create mode 100644 src/backend/core/tests/fixtures/matrix.py create mode 100644 src/backend/core/tests/utils/test_webhooks_matrix_client.py create mode 100644 src/backend/core/utils/matrix.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb4dc6..dbedce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(teams) add matrix webhook for teams #904 - ✨(resource-server) add SCIM /Me endpoint #895 - 🔧(git) set LF line endings for all text files #928 diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index e1924a5..bd73e11 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices): FAILURE = "failure", _("Failure") PENDING = "pending", _("Pending") SUCCESS = "success", _("Success") + + +class WebhookProtocolChoices(models.TextChoices): + """Defines the possible protocols of webhook.""" + + SCIM = "scim" + MATRIX = "matrix" diff --git a/src/backend/core/migrations/0017_teamwebhook_protocol.py b/src/backend/core/migrations/0017_teamwebhook_protocol.py new file mode 100644 index 0000000..c7fb7ba --- /dev/null +++ b/src/backend/core/migrations/0017_teamwebhook_protocol.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-06-17 14:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_team_external_id_alter_team_users'), + ] + + operations = [ + migrations.AddField( + model_name='teamwebhook', + name='protocol', + field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 3c1d62c..4c14c2f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -21,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField from django.contrib.sites.models import Site from django.core import exceptions, mail, validators from django.core.exceptions import ValidationError -from django.db import models, transaction +from django.db import models from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import gettext, override @@ -31,9 +31,9 @@ import jsonschema from timezone_field import TimeZoneField from treebeard.mp_tree import MP_Node, MP_NodeManager -from core.enums import WebhookStatusChoices +from core.enums import WebhookProtocolChoices, WebhookStatusChoices from core.plugins.registry import registry as plugin_hooks_registry -from core.utils.webhooks import scim_synchronizer +from core.utils.webhooks import webhooks_synchronizer from core.validators import get_field_validators_from_setting logger = getLogger(__name__) @@ -864,16 +864,12 @@ class TeamAccess(BaseModel): Override save function to fire webhooks on any addition or update to a team access. """ - - if self._state.adding: + if self._state.adding and self.team.webhooks.exists(): self.team.webhooks.update(status=WebhookStatusChoices.PENDING) - with transaction.atomic(): - instance = super().save(*args, **kwargs) - scim_synchronizer.add_user_to_group(self.team, self.user) - else: - instance = super().save(*args, **kwargs) + # try to synchronize all webhooks + webhooks_synchronizer.add_user_to_group(self.team, self.user) - return instance + return super().save(*args, **kwargs) def delete(self, *args, **kwargs): """ @@ -881,11 +877,12 @@ class TeamAccess(BaseModel): Don't allow deleting a team access until it is successfully synchronized with all its webhooks. """ - self.team.webhooks.update(status=WebhookStatusChoices.PENDING) - with transaction.atomic(): - arguments = self.team, self.user - super().delete(*args, **kwargs) - scim_synchronizer.remove_user_from_group(*arguments) + if webhooks := self.team.webhooks.all(): + webhooks.update(status=WebhookStatusChoices.PENDING) + # try to synchronize all webhooks + webhooks_synchronizer.remove_user_from_group(self.team, self.user) + + super().delete(*args, **kwargs) def get_abilities(self, user): """ @@ -943,6 +940,11 @@ class TeamWebhook(BaseModel): team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE) url = models.URLField(_("url")) secret = models.CharField(_("secret"), max_length=255, null=True, blank=True) + protocol = models.CharField( + max_length=None, + default=WebhookProtocolChoices.SCIM, + choices=WebhookProtocolChoices.choices, + ) status = models.CharField( max_length=10, default=WebhookStatusChoices.PENDING, diff --git a/src/backend/core/tests/fixtures/__init__.py b/src/backend/core/tests/fixtures/__init__.py new file mode 100644 index 0000000..18ef0e3 --- /dev/null +++ b/src/backend/core/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Test fixtures.""" diff --git a/src/backend/core/tests/fixtures/matrix.py b/src/backend/core/tests/fixtures/matrix.py new file mode 100644 index 0000000..9ce5f4f --- /dev/null +++ b/src/backend/core/tests/fixtures/matrix.py @@ -0,0 +1,75 @@ +"""Define here some fake responses from Matrix API, useful to mock responses in tests.""" + +from rest_framework import status + + +# JOIN ROOMS +def mock_join_room_successful(room_id): + """Mock response when succesfully joining room. Same response if already in room.""" + return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK} + + +def mock_join_room_no_known_servers(): + """Mock response when room to join cannot be found.""" + return { + "message": {"errcode": "M_UNKNOWN", "error": "No known servers"}, + "status_code": status.HTTP_404_NOT_FOUND, + } + + +def mock_join_room_forbidden(): + """Mock response when room cannot be joined.""" + return { + "message": { + "errcode": "M_FORBIDDEN", + "error": "You do not belong to any of the required rooms/spaces to join this room.", + }, + "status_code": status.HTTP_403_FORBIDDEN, + } + + +# INVITE USER +def mock_invite_successful(): + """Mock response when invite request was succesful. Does not check the user exists.""" + return {"message": {}, "status_code": status.HTTP_200_OK} + + +def mock_invite_user_already_in_room(user): + """Mock response when invitation forbidden for People user.""" + return { + "message": { + "errcode": "M_FORBIDDEN", + "error": f"{user.email.replace('@', ':')} is already in the room.", + }, + "status_code": status.HTTP_403_FORBIDDEN, + } + + +# KICK USER +def mock_kick_successful(): + """Mock response when succesfully joining room.""" + return {"message": {}, "status_code": status.HTTP_200_OK} + + +def mock_kick_user_forbidden(user): + """Mock response when kick request is forbidden (i.e. wrong permission or user is room admin.""" + return { + "message": { + "errcode": "M_FORBIDDEN", + "error": f"You cannot kick user @{user.email.replace('@', ':')}.", + }, + "status_code": status.HTTP_403_FORBIDDEN, + } + + +def mock_kick_user_not_in_room(): + """ + Mock response when trying to kick a user who isn't in the room. Don't check the user exists. + """ + return { + "message": { + "errcode": "M_FORBIDDEN", + "error": "The target user is not in the room", + }, + "status_code": status.HTTP_403_FORBIDDEN, + } diff --git a/src/backend/core/tests/team_accesses/test_api_team_accesses_create.py b/src/backend/core/tests/team_accesses/test_api_team_accesses_create.py index ff22514..94d04c4 100644 --- a/src/backend/core/tests/team_accesses/test_api_team_accesses_create.py +++ b/src/backend/core/tests/team_accesses/test_api_team_accesses_create.py @@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create """ import json +import logging import random import re import pytest import responses +from rest_framework import status from rest_framework.test import APIClient -from core import factories, models +from core import enums, factories, models +from core.tests.fixtures import matrix pytestmark = pytest.mark.django_db @@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner(): } -def test_api_team_accesses_create_webhook(): +def test_api_team_accesses_create__with_scim_webhook(): """ - When the team has a webhook, creating a team access should fire a call. + If a team has a SCIM webhook, creating a team access should fire a call + with the expected payload. """ user, other_user = factories.UserFactory.create_batch(2) team = factories.TeamFactory(users=[(user, "owner")]) - webhook = factories.TeamWebhookFactory(team=team) + webhook = factories.TeamWebhookFactory( + team=team, protocol=enums.WebhookProtocolChoices.SCIM + ) role = random.choice([role[0] for role in models.RoleChoices.choices]) @@ -226,3 +232,139 @@ def test_api_team_accesses_create_webhook(): } ], } + + assert models.TeamAccess.objects.filter(user=other_user, team=team).exists() + + +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. + If all responses are positive, proceeds to add the user to the team. + """ + caplog.set_level(logging.INFO) + + user, other_user = factories.UserFactory.create_batch(2) + + team = factories.TeamFactory(users=[(user, "owner")]) + webhook_scim = factories.TeamWebhookFactory( + team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh" + ) + webhook_matrix = factories.TeamWebhookFactory( + team=team, + url="https://www.webhookserver.fr/#/room/room_id:home_server/", + protocol=enums.WebhookProtocolChoices.MATRIX, + secret="yo", + ) + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + client = APIClient() + client.force_login(user) + + with responses.RequestsMock() as rsps: + # Ensure successful response by scim provider using "responses": + rsps.add( + rsps.PATCH, + re.compile(r".*/Groups/.*"), + body="{}", + status=200, + content_type="application/json", + ) + rsps.add( + rsps.POST, + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful), + status=status.HTTP_200_OK, + content_type="application/json", + ) + rsps.add( + rsps.POST, + re.compile(r".*/invite"), + body=str(matrix.mock_invite_successful()["message"]), + status=matrix.mock_invite_successful()["status_code"], + content_type="application/json", + ) + + response = client.post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + assert response.status_code == 201 + + # Logger + log_messages = [msg.message for msg in caplog.records] + for webhook in [webhook_scim, webhook_matrix]: + assert ( + f"add_user_to_group synchronization succeeded with {webhook.url}" + in log_messages + ) + + # Status + for webhook in [webhook_scim, webhook_matrix]: + webhook.refresh_from_db() + assert webhook.status == "success" + assert models.TeamAccess.objects.filter(user=other_user, team=team).exists() + + +@responses.activate +def test_api_team_accesses_create__multiple_webhooks_failure(caplog): + """When a webhook fails, user should still be added to the team.""" + caplog.set_level(logging.INFO) + + user, other_user = factories.UserFactory.create_batch(2) + + team = factories.TeamFactory(users=[(user, "owner")]) + webhook_scim = factories.TeamWebhookFactory( + team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh" + ) + webhook_matrix = factories.TeamWebhookFactory( + team=team, + url="https://www.webhookserver.fr/#/room/room_id:home_server/", + protocol=enums.WebhookProtocolChoices.MATRIX, + secret="secret", + ) + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + client = APIClient() + client.force_login(user) + + responses.patch( + re.compile(r".*/Groups/.*"), + body="{}", + status=200, + ) + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_forbidden()["message"]), + status=str(matrix.mock_join_room_forbidden()["status_code"]), + ) + + response = client.post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + # Logger + log_messages = [msg.message for msg in caplog.records] + assert ( + f"add_user_to_group synchronization succeeded with {webhook_scim.url}" + in log_messages + ) + assert ( + f"add_user_to_group synchronization failed with {webhook_matrix.url}" + in log_messages + ) + + # Status + webhook_scim.status = "success" + webhook_matrix.status = "failure" + assert models.TeamAccess.objects.filter(user=other_user, team=team).exists() diff --git a/src/backend/core/tests/utils/test_webhooks_matrix_client.py b/src/backend/core/tests/utils/test_webhooks_matrix_client.py new file mode 100644 index 0000000..474da54 --- /dev/null +++ b/src/backend/core/tests/utils/test_webhooks_matrix_client.py @@ -0,0 +1,277 @@ +"""Test Team synchronization webhooks : focus on matrix client.""" + +import json +import logging +import re + +from django.test import override_settings + +import pytest +import responses +from rest_framework import status + +from core import factories +from core.enums import WebhookProtocolChoices +from core.tests.fixtures import matrix +from core.utils.webhooks import webhooks_synchronizer + +pytestmark = pytest.mark.django_db + + +## INVITE +@responses.activate +def test_matrix_webhook__invite_user_to_room_forbidden(caplog): + """Cannot invite when Matrix returns forbidden. This might mean the user is an admin.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + # Mock successful responses + error = matrix.mock_kick_user_forbidden(user) + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful), + status=status.HTTP_200_OK, + ) + responses.post( + re.compile(r".*/invite"), + body=str(error["message"]), + status=error["status_code"], + ) + webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user) + + +@responses.activate +def test_matrix_webhook__invite_user_to_room_already_in_room(caplog): + """If user is already in room, webhooks should be set to success.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + # Mock successful responses + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful("room_id")["message"]), + status=matrix.mock_join_room_successful("room_id")["status_code"], + ) + responses.post( + re.compile(r".*/invite"), + body=str(matrix.mock_invite_user_already_in_room(user)["message"]), + status=matrix.mock_invite_user_already_in_room(user)["status_code"], + ) + webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user) + + # Logger + log_messages = [msg.message for msg in caplog.records] + expected_messages = ( + f"add_user_to_group synchronization succeeded with {webhook.url}" + ) + assert expected_messages in log_messages + + # Status + webhook.refresh_from_db() + assert webhook.status == "success" + + +@responses.activate +def test_matrix_webhook__invite_user_to_room_success(caplog): + """The user passed to the function should get invited.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + # Mock successful responses + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful("room_id")["message"]), + status=matrix.mock_join_room_successful("room_id")["status_code"], + ) + responses.post( + re.compile(r".*/invite"), + body=str(matrix.mock_invite_successful()["message"]), + status=matrix.mock_invite_successful()["status_code"], + ) + webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user) + + # Check headers + headers = responses.calls[0].request.headers + assert webhook.secret in headers["Authorization"] + + # Check payloads sent to Matrix API + assert json.loads(responses.calls[1].request.body) == { + "user_id": f"@{user.email.replace('@', ':')}", + "reason": f"User added to team {webhook.team} on People", + } + + # Logger + log_messages = [msg.message for msg in caplog.records] + expected_messages = ( + f"add_user_to_group synchronization succeeded with {webhook.url}" + ) + assert expected_messages in log_messages + + # Status + webhook.refresh_from_db() + assert webhook.status == "success" + + +@responses.activate +@override_settings(TCHAP_ACCESS_TOKEN="TCHAP_TOKEN") +def test_matrix_webhook__override_secret_for_tchap(): + """The user passed to the function should get invited.""" + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.tchap.gouv.fr/#/room/room_id:home_server", + secret="secret-about-to-be-overridden", + ) + + # Mock successful responses + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful("room_id")["message"]), + status=matrix.mock_join_room_successful("room_id")["status_code"], + ) + responses.post( + re.compile(r".*/invite"), + body=str(matrix.mock_invite_successful()["message"]), + status=matrix.mock_invite_successful()["status_code"], + ) + webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user) + + # Check headers + headers = responses.calls[0].request.headers + assert "TCHAP_TOKEN" in headers["Authorization"] + + +## KICK +@responses.activate +def test_matrix_webhook__kick_user_from_room_not_in_room(caplog): + """Webhook should report a success when user was already not in room.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + # Mock successful responses + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful), + status=status.HTTP_200_OK, + ) + responses.post( + re.compile(r".*/kick"), + body=str(matrix.mock_kick_user_not_in_room()["message"]), + status=matrix.mock_kick_user_not_in_room()["status_code"], + ) + webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user) + + # Logger + log_messages = [msg.message for msg in caplog.records] + assert ( + f"remove_user_from_group synchronization succeeded with {webhook.url}" + in log_messages + ) + + # Status + webhook.refresh_from_db() + assert webhook.status == "success" + + +@responses.activate +def test_matrix_webhook__kick_user_from_room_success(caplog): + """The user passed to the function should get removed.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful), + status=status.HTTP_200_OK, + ) + responses.post( + re.compile(r".*/kick"), + body=str(matrix.mock_kick_successful), + status=status.HTTP_200_OK, + ) + webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user) + + # Check payloads sent to Matrix API + assert json.loads(responses.calls[1].request.body) == { + "user_id": f"@{user.email.replace('@', ':')}", + "reason": f"User removed from team {webhook.team} on People", + } + + # Logger + log_messages = [msg.message for msg in caplog.records] + expected_messages = ( + f"remove_user_from_group synchronization succeeded with {webhook.url}" + ) + assert expected_messages in log_messages + + # Status + webhook.refresh_from_db() + assert webhook.status == "success" + + +@responses.activate +def test_matrix_webhook__kick_user_from_room_forbidden(caplog): + """Cannot kick an admin.""" + caplog.set_level(logging.INFO) + + user = factories.UserFactory() + webhook = factories.TeamWebhookFactory( + protocol=WebhookProtocolChoices.MATRIX, + url="https://www.matrix.org/#/room/room_id:home_server", + secret="secret-access-token", + ) + + # Mock successful responses + error = matrix.mock_kick_user_forbidden(user) + responses.post( + re.compile(r".*/join"), + body=str(matrix.mock_join_room_successful), + status=status.HTTP_200_OK, + ) + responses.post( + re.compile(r".*/kick"), + body=str(error["message"]), + status=error["status_code"], + ) + webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user) + + # Logger + log_messages = [msg.message for msg in caplog.records] + assert ( + f"remove_user_from_group synchronization failed with {webhook.url}" + in log_messages + ) + + # Status + webhook.refresh_from_db() + assert webhook.status == "failure" diff --git a/src/backend/core/tests/utils/test_webhooks_scim_client.py b/src/backend/core/tests/utils/test_webhooks_scim_client.py index 57b77be..90e4074 100644 --- a/src/backend/core/tests/utils/test_webhooks_scim_client.py +++ b/src/backend/core/tests/utils/test_webhooks_scim_client.py @@ -1,4 +1,4 @@ -"""Test Team synchronization webhooks.""" +"""Test Team synchronization webhooks : focus on scim client""" import json import random @@ -10,7 +10,7 @@ import pytest import responses from core import factories -from core.utils.webhooks import scim_synchronizer +from core.utils.webhooks import webhooks_synchronizer pytestmark = pytest.mark.django_db @@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks(): access = factories.TeamAccessFactory() with responses.RequestsMock(): - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) assert len(responses.calls) == 0 @@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info): content_type="application/json", ) - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) for i, webhook in enumerate(webhooks): assert rsps.calls[i].request.url == webhook.url @@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info): content_type="application/json", ) - scim_synchronizer.remove_user_from_group(access.team, access.user) + webhooks_synchronizer.remove_user_from_group(access.team, access.user) for i, webhook in enumerate(webhooks): assert rsps.calls[i].request.url == webhook.url @@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error): rsps.PATCH, re.compile(r".*/Groups/.*"), body="{}", - status=random.choice([404, 301, 302]), + status=404, content_type="application/json", ) - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) for i, webhook in enumerate(webhooks): assert rsps.calls[i].request.url == webhook.url @@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error): rsps.add(rsps.PATCH, url, status=200, content_type="application/json"), ] - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) for i in range(4): assert all_rsps[i].call_count == 1 @@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error): content_type="application/json", ) - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) assert rsp.call_count == 5 assert rsps.calls[0].request.url == webhook.url @@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization(): content_type="application/json", ) - scim_synchronizer.add_user_to_group(access.team, access.user) + webhooks_synchronizer.add_user_to_group(access.team, access.user) assert rsps.calls[0].request.url == webhook.url # Check headers diff --git a/src/backend/core/utils/matrix.py b/src/backend/core/utils/matrix.py new file mode 100644 index 0000000..8778f73 --- /dev/null +++ b/src/backend/core/utils/matrix.py @@ -0,0 +1,136 @@ +"""Matrix client for interoperability to synchronize with remote service providers.""" + +import logging + +from django.conf import settings + +import requests +from rest_framework.status import ( + HTTP_200_OK, +) +from urllib3.util import Retry + +logger = logging.getLogger(__name__) + +adapter = requests.adapters.HTTPAdapter( + max_retries=Retry( + total=4, + backoff_factor=0.1, + status_forcelist=[500, 502], + allowed_methods=["POST"], + ) +) + +session = requests.Session() +session.mount("http://", adapter) +session.mount("https://", adapter) + + +class MatrixAPIClient: + """A client to interact with Matrix API""" + + secret = settings.TCHAP_ACCESS_TOKEN + + def get_headers(self, webhook): + """Build header dict from webhook object.""" + headers = {"Content-Type": "application/json"} + token = webhook.secret if webhook.secret else None + if "tchap.gouv.fr" in webhook.url: + token = settings.TCHAP_ACCESS_TOKEN + headers["Authorization"] = f"Bearer {token}" + return headers + + def _get_room_url(self, webhook_url): + """Returns room id from webhook url.""" + room_id = webhook_url.split("/room/")[1] + base_url = room_id.split(":")[1] + if "tchap.gouv.fr" in webhook_url: + base_url = f"matrix.{base_url}" + return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}" + + def get_user_id(self, user): + """Returns user id from email.""" + if user.email is None: + raise ValueError("You must first set an email for the user.") + + return f"@{user.email.replace('@', ':')}" + + def join_room(self, webhook): + """Accept invitation to the room. As of today, it is a mandatory step + to make sure our account will be able to invite/remove users.""" + if webhook.secret is None: + raise ValueError("Please configure this webhook's secret access token.") + + return session.post( + f"{self._get_room_url(webhook.url)}/join", + json={}, + headers=self.get_headers(webhook), + verify=False, + timeout=3, + ) + + def add_user_to_group(self, webhook, user): + """Send request to invite an user to a room or space upon adding them to group..""" + join_response = self.join_room(webhook) + if join_response.status_code != HTTP_200_OK: + logger.error( + "Synchronization failed (cannot join room) %s", + webhook.url, + ) + return join_response, False + + user_id = self.get_user_id(user) + response = session.post( + f"{self._get_room_url(webhook.url)}/invite", + json={ + "user_id": user_id, + "reason": f"User added to team {webhook.team} on People", + }, + headers=self.get_headers(webhook), + verify=False, + timeout=3, + ) + + # Checks for false negative + # (i.e. trying to invite user already in room) + webhook_succeeded = False + if ( + response.status_code == HTTP_200_OK + or b"is already in the room." in response.content + ): + webhook_succeeded = True + + return response, webhook_succeeded + + def remove_user_from_group(self, webhook, user): + """Send request to kick an user from a room or space upon removing them from group.""" + join_response = self.join_room(webhook) + if join_response.status_code != HTTP_200_OK: + logger.error( + "Synchronization failed (cannot join room) %s", + webhook.url, + ) + return join_response, False + + user_id = self.get_user_id(user) + response = session.post( + f"{self._get_room_url(webhook.url)}/kick", + json={ + "user_id": user_id, + "reason": f"User removed from team {webhook.team} on People", + }, + headers=self.get_headers(webhook), + verify=False, + timeout=3, + ) + + # Checks for false negative + # (i.e. trying to remove user who already left the room) + webhook_succeeded = False + if ( + response.status_code == HTTP_200_OK + or b"The target user is not in the room" in response.content + ): + webhook_succeeded = True + + return response, webhook_succeeded diff --git a/src/backend/core/utils/scim.py b/src/backend/core/utils/scim.py index a5d198c..08d50a0 100644 --- a/src/backend/core/utils/scim.py +++ b/src/backend/core/utils/scim.py @@ -39,7 +39,7 @@ class SCIMClient: ], } - return session.patch( + response = session.patch( webhook.url, json=payload, headers=webhook.get_headers(), @@ -47,6 +47,8 @@ class SCIMClient: timeout=3, ) + return response, response.ok + def remove_user_from_group(self, webhook, user): """Remove a user from a group by its ID or email.""" payload = { @@ -61,10 +63,12 @@ class SCIMClient: } ], } - return session.patch( + response = session.patch( webhook.url, json=payload, headers=webhook.get_headers(), verify=False, timeout=3, ) + + return response, response.ok diff --git a/src/backend/core/utils/webhooks.py b/src/backend/core/utils/webhooks.py index d011062..f0f06a1 100644 --- a/src/backend/core/utils/webhooks.py +++ b/src/backend/core/utils/webhooks.py @@ -4,14 +4,16 @@ import logging import requests +from core import enums from core.enums import WebhookStatusChoices +from .matrix import MatrixAPIClient from .scim import SCIMClient logger = logging.getLogger(__name__) -class WebhookSCIMClient: +class WebhookClient: """Wraps the SCIM client to record call results on webhooks.""" def __getattr__(self, name): @@ -26,31 +28,15 @@ class WebhookSCIMClient: if not webhook.url: continue - client = SCIMClient() status = WebhookStatusChoices.FAILURE - try: - response = getattr(client, name)(webhook, user) + response, webhook_succeeded = self._get_response_and_status( + name, webhook, user + ) - except requests.exceptions.RetryError as exc: - logger.error( - "%s synchronization failed due to max retries exceeded with url %s", - name, - webhook.url, - exc_info=exc, - ) - except requests.exceptions.RequestException as exc: - logger.error( - "%s synchronization failed with %s.", - name, - webhook.url, - exc_info=exc, - ) - else: - extra = { - "response": response.content, - } + if response is not None: + extra = {"response": response.content} # pylint: disable=no-member - if response.status_code == requests.codes.ok: + if webhook_succeeded: logger.info( "%s synchronization succeeded with %s", name, @@ -71,5 +57,37 @@ class WebhookSCIMClient: return wrapper + def _get_client(self, webhook): + """Get client depending on the protocol.""" + if webhook.protocol == enums.WebhookProtocolChoices.MATRIX: + return MatrixAPIClient() -scim_synchronizer = WebhookSCIMClient() + return SCIMClient() + + def _get_response_and_status(self, name, webhook, user): + """Get response from webhook outside party.""" + client = self._get_client(webhook) + + try: + response, webhook_succeeded = getattr(client, name)(webhook, user) + except requests.exceptions.RetryError as exc: + logger.error( + "%s synchronization failed due to max retries exceeded with url %s", + name, + webhook.url, + exc_info=exc, + ) + except requests.exceptions.RequestException as exc: + logger.error( + "%s synchronization failed with %s.", + name, + webhook.url, + exc_info=exc, + ) + else: + return response, webhook_succeeded + + return None, False + + +webhooks_synchronizer = WebhookClient() diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 8e68cb2..27fc2f4 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -601,6 +601,11 @@ class Base(Configuration): environ_name="DNS_PROVISIONING_API_CREDENTIALS", environ_prefix=None, ) + TCHAP_ACCESS_TOKEN = values.Value( + default=None, + environ_name="TCHAP_ACCESS_TOKEN", + environ_prefix=None, + ) # Organizations ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads( diff --git a/src/helm/env.d/dev/values.desk.yaml.gotmpl b/src/helm/env.d/dev/values.desk.yaml.gotmpl index 52d8b8e..47ecf0e 100644 --- a/src/helm/env.d/dev/values.desk.yaml.gotmpl +++ b/src/helm/env.d/dev/values.desk.yaml.gotmpl @@ -73,6 +73,7 @@ backend: secretKeyRef: name: backend key: MAIL_PROVISIONING_API_CREDENTIALS + TCHAP_ACCESS_TOKEN: service_account_key command: - "gunicorn" - "-c"