Skip to content

Commit cc39ed5

Browse files
mjeammetqbey
authored andcommitted
✨(teams) add matrix webhook for teams
A webhook to invite/kick team members to a matrix room.
1 parent 7bebf13 commit cc39ed5

File tree

14 files changed

+745
-58
lines changed

14 files changed

+745
-58
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
### Added
1212

13+
- ✨(teams) add matrix webhook for teams #904
1314
- ✨(resource-server) add SCIM /Me endpoint #895
1415
- 🔧(git) set LF line endings for all text files #928
1516

src/backend/core/enums.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
2424
FAILURE = "failure", _("Failure")
2525
PENDING = "pending", _("Pending")
2626
SUCCESS = "success", _("Success")
27+
28+
29+
class WebhookProtocolChoices(models.TextChoices):
30+
"""Defines the possible protocols of webhook."""
31+
32+
SCIM = "scim"
33+
MATRIX = "matrix"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.3 on 2025-06-17 14:23
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0016_team_external_id_alter_team_users'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='teamwebhook',
15+
name='protocol',
16+
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
17+
),
18+
]

src/backend/core/models.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from django.contrib.sites.models import Site
2222
from django.core import exceptions, mail, validators
2323
from django.core.exceptions import ValidationError
24-
from django.db import models, transaction
24+
from django.db import models
2525
from django.template.loader import render_to_string
2626
from django.utils import timezone
2727
from django.utils.translation import gettext, override
@@ -31,9 +31,9 @@
3131
from timezone_field import TimeZoneField
3232
from treebeard.mp_tree import MP_Node, MP_NodeManager
3333

34-
from core.enums import WebhookStatusChoices
34+
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
3535
from core.plugins.registry import registry as plugin_hooks_registry
36-
from core.utils.webhooks import scim_synchronizer
36+
from core.utils.webhooks import webhooks_synchronizer
3737
from core.validators import get_field_validators_from_setting
3838

3939
logger = getLogger(__name__)
@@ -864,28 +864,25 @@ def save(self, *args, **kwargs):
864864
Override save function to fire webhooks on any addition or update
865865
to a team access.
866866
"""
867-
868-
if self._state.adding:
867+
if self._state.adding and self.team.webhooks.exists():
869868
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
870-
with transaction.atomic():
871-
instance = super().save(*args, **kwargs)
872-
scim_synchronizer.add_user_to_group(self.team, self.user)
873-
else:
874-
instance = super().save(*args, **kwargs)
869+
# try to synchronize all webhooks
870+
webhooks_synchronizer.add_user_to_group(self.team, self.user)
875871

876-
return instance
872+
return super().save(*args, **kwargs)
877873

878874
def delete(self, *args, **kwargs):
879875
"""
880876
Override delete method to fire webhooks on to team accesses.
881877
Don't allow deleting a team access until it is successfully synchronized with all
882878
its webhooks.
883879
"""
884-
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
885-
with transaction.atomic():
886-
arguments = self.team, self.user
887-
super().delete(*args, **kwargs)
888-
scim_synchronizer.remove_user_from_group(*arguments)
880+
if webhooks := self.team.webhooks.all():
881+
webhooks.update(status=WebhookStatusChoices.PENDING)
882+
# try to synchronize all webhooks
883+
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
884+
885+
super().delete(*args, **kwargs)
889886

890887
def get_abilities(self, user):
891888
"""
@@ -943,6 +940,11 @@ class TeamWebhook(BaseModel):
943940
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
944941
url = models.URLField(_("url"))
945942
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
943+
protocol = models.CharField(
944+
max_length=None,
945+
default=WebhookProtocolChoices.SCIM,
946+
choices=WebhookProtocolChoices.choices,
947+
)
946948
status = models.CharField(
947949
max_length=10,
948950
default=WebhookStatusChoices.PENDING,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test fixtures."""
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
2+
3+
from rest_framework import status
4+
5+
6+
# JOIN ROOMS
7+
def mock_join_room_successful(room_id):
8+
"""Mock response when succesfully joining room. Same response if already in room."""
9+
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
10+
11+
12+
def mock_join_room_no_known_servers():
13+
"""Mock response when room to join cannot be found."""
14+
return {
15+
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
16+
"status_code": status.HTTP_404_NOT_FOUND,
17+
}
18+
19+
20+
def mock_join_room_forbidden():
21+
"""Mock response when room cannot be joined."""
22+
return {
23+
"message": {
24+
"errcode": "M_FORBIDDEN",
25+
"error": "You do not belong to any of the required rooms/spaces to join this room.",
26+
},
27+
"status_code": status.HTTP_403_FORBIDDEN,
28+
}
29+
30+
31+
# INVITE USER
32+
def mock_invite_successful():
33+
"""Mock response when invite request was succesful. Does not check the user exists."""
34+
return {"message": {}, "status_code": status.HTTP_200_OK}
35+
36+
37+
def mock_invite_user_already_in_room(user):
38+
"""Mock response when invitation forbidden for People user."""
39+
return {
40+
"message": {
41+
"errcode": "M_FORBIDDEN",
42+
"error": f"{user.email.replace('@', ':')} is already in the room.",
43+
},
44+
"status_code": status.HTTP_403_FORBIDDEN,
45+
}
46+
47+
48+
# KICK USER
49+
def mock_kick_successful():
50+
"""Mock response when succesfully joining room."""
51+
return {"message": {}, "status_code": status.HTTP_200_OK}
52+
53+
54+
def mock_kick_user_forbidden(user):
55+
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
56+
return {
57+
"message": {
58+
"errcode": "M_FORBIDDEN",
59+
"error": f"You cannot kick user @{user.email.replace('@', ':')}.",
60+
},
61+
"status_code": status.HTTP_403_FORBIDDEN,
62+
}
63+
64+
65+
def mock_kick_user_not_in_room():
66+
"""
67+
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
68+
"""
69+
return {
70+
"message": {
71+
"errcode": "M_FORBIDDEN",
72+
"error": "The target user is not in the room",
73+
},
74+
"status_code": status.HTTP_403_FORBIDDEN,
75+
}

src/backend/core/tests/team_accesses/test_api_team_accesses_create.py

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
"""
44

55
import json
6+
import logging
67
import random
78
import re
89

910
import pytest
1011
import responses
12+
from rest_framework import status
1113
from rest_framework.test import APIClient
1214

13-
from core import factories, models
15+
from core import enums, factories, models
16+
from core.tests.fixtures import matrix
1417

1518
pytestmark = pytest.mark.django_db
1619

@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
171174
}
172175

173176

174-
def test_api_team_accesses_create_webhook():
177+
def test_api_team_accesses_create__with_scim_webhook():
175178
"""
176-
When the team has a webhook, creating a team access should fire a call.
179+
If a team has a SCIM webhook, creating a team access should fire a call
180+
with the expected payload.
177181
"""
178182
user, other_user = factories.UserFactory.create_batch(2)
179183

180184
team = factories.TeamFactory(users=[(user, "owner")])
181-
webhook = factories.TeamWebhookFactory(team=team)
185+
webhook = factories.TeamWebhookFactory(
186+
team=team, protocol=enums.WebhookProtocolChoices.SCIM
187+
)
182188

183189
role = random.choice([role[0] for role in models.RoleChoices.choices])
184190

@@ -226,3 +232,139 @@ def test_api_team_accesses_create_webhook():
226232
}
227233
],
228234
}
235+
236+
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
237+
238+
239+
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
240+
"""
241+
When the team has multiple webhooks, creating a team access should fire all the expected calls.
242+
If all responses are positive, proceeds to add the user to the team.
243+
"""
244+
caplog.set_level(logging.INFO)
245+
246+
user, other_user = factories.UserFactory.create_batch(2)
247+
248+
team = factories.TeamFactory(users=[(user, "owner")])
249+
webhook_scim = factories.TeamWebhookFactory(
250+
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
251+
)
252+
webhook_matrix = factories.TeamWebhookFactory(
253+
team=team,
254+
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
255+
protocol=enums.WebhookProtocolChoices.MATRIX,
256+
secret="yo",
257+
)
258+
259+
role = random.choice([role[0] for role in models.RoleChoices.choices])
260+
261+
client = APIClient()
262+
client.force_login(user)
263+
264+
with responses.RequestsMock() as rsps:
265+
# Ensure successful response by scim provider using "responses":
266+
rsps.add(
267+
rsps.PATCH,
268+
re.compile(r".*/Groups/.*"),
269+
body="{}",
270+
status=200,
271+
content_type="application/json",
272+
)
273+
rsps.add(
274+
rsps.POST,
275+
re.compile(r".*/join"),
276+
body=str(matrix.mock_join_room_successful),
277+
status=status.HTTP_200_OK,
278+
content_type="application/json",
279+
)
280+
rsps.add(
281+
rsps.POST,
282+
re.compile(r".*/invite"),
283+
body=str(matrix.mock_invite_successful()["message"]),
284+
status=matrix.mock_invite_successful()["status_code"],
285+
content_type="application/json",
286+
)
287+
288+
response = client.post(
289+
f"/api/v1.0/teams/{team.id!s}/accesses/",
290+
{
291+
"user": str(other_user.id),
292+
"role": role,
293+
},
294+
format="json",
295+
)
296+
assert response.status_code == 201
297+
298+
# Logger
299+
log_messages = [msg.message for msg in caplog.records]
300+
for webhook in [webhook_scim, webhook_matrix]:
301+
assert (
302+
f"add_user_to_group synchronization succeeded with {webhook.url}"
303+
in log_messages
304+
)
305+
306+
# Status
307+
for webhook in [webhook_scim, webhook_matrix]:
308+
webhook.refresh_from_db()
309+
assert webhook.status == "success"
310+
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
311+
312+
313+
@responses.activate
314+
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
315+
"""When a webhook fails, user should still be added to the team."""
316+
caplog.set_level(logging.INFO)
317+
318+
user, other_user = factories.UserFactory.create_batch(2)
319+
320+
team = factories.TeamFactory(users=[(user, "owner")])
321+
webhook_scim = factories.TeamWebhookFactory(
322+
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
323+
)
324+
webhook_matrix = factories.TeamWebhookFactory(
325+
team=team,
326+
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
327+
protocol=enums.WebhookProtocolChoices.MATRIX,
328+
secret="secret",
329+
)
330+
331+
role = random.choice([role[0] for role in models.RoleChoices.choices])
332+
client = APIClient()
333+
client.force_login(user)
334+
335+
responses.patch(
336+
re.compile(r".*/Groups/.*"),
337+
body="{}",
338+
status=200,
339+
)
340+
responses.post(
341+
re.compile(r".*/join"),
342+
body=str(matrix.mock_join_room_forbidden()["message"]),
343+
status=str(matrix.mock_join_room_forbidden()["status_code"]),
344+
)
345+
346+
response = client.post(
347+
f"/api/v1.0/teams/{team.id!s}/accesses/",
348+
{
349+
"user": str(other_user.id),
350+
"role": role,
351+
},
352+
format="json",
353+
)
354+
assert response.status_code == status.HTTP_201_CREATED
355+
356+
# Logger
357+
log_messages = [msg.message for msg in caplog.records]
358+
assert (
359+
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
360+
in log_messages
361+
)
362+
assert (
363+
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
364+
in log_messages
365+
)
366+
367+
# Status
368+
webhook_scim.status = "success"
369+
webhook_matrix.status = "failure"
370+
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()

0 commit comments

Comments
 (0)