✨(teams) add matrix webhook for teams
A webhook to invite/kick team members to a matrix room.
This commit is contained in:
committed by
Quentin BEY
parent
7bebf13d88
commit
cc39ed5298
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
18
src/backend/core/migrations/0017_teamwebhook_protocol.py
Normal file
18
src/backend/core/migrations/0017_teamwebhook_protocol.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
1
src/backend/core/tests/fixtures/__init__.py
vendored
Normal file
1
src/backend/core/tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"""Test fixtures."""
|
||||
75
src/backend/core/tests/fixtures/matrix.py
vendored
Normal file
75
src/backend/core/tests/fixtures/matrix.py
vendored
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
277
src/backend/core/tests/utils/test_webhooks_matrix_client.py
Normal file
277
src/backend/core/tests/utils/test_webhooks_matrix_client.py
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
136
src/backend/core/utils/matrix.py
Normal file
136
src/backend/core/utils/matrix.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -73,6 +73,7 @@ backend:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: MAIL_PROVISIONING_API_CREDENTIALS
|
||||
TCHAP_ACCESS_TOKEN: service_account_key
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
|
||||
Reference in New Issue
Block a user