(teams) add matrix webhook for teams

A webhook to invite/kick team members to a matrix room.
This commit is contained in:
Marie PUPO JEAMMET
2025-05-09 17:49:20 +02:00
committed by Quentin BEY
parent 7bebf13d88
commit cc39ed5298
14 changed files with 743 additions and 56 deletions

View File

@@ -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

View File

@@ -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"

View 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'),
),
]

View File

@@ -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
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)
scim_synchronizer.remove_user_from_group(*arguments)
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,

View File

@@ -0,0 +1 @@
"""Test fixtures."""

View 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,
}

View File

@@ -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()

View 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"

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -73,6 +73,7 @@ backend:
secretKeyRef:
name: backend
key: MAIL_PROVISIONING_API_CREDENTIALS
TCHAP_ACCESS_TOKEN: service_account_key
command:
- "gunicorn"
- "-c"