Skip to content

Commit dc34fcd

Browse files
grichaclaude
andauthored
feat(integrations): Propagate ViewerContext in MS Teams webhook (#112414)
## Summary - Wraps `_handle_action_submitted` (after group/integration validation, where `group.organization.id` is concrete) and `_handle_team_member_removed` (per-org loop) with `webhook_viewer_context`. - Gated behind the `viewer-context.enabled` option (no-op when disabled). - Part of the ViewerContext RFC rollout for webhook handlers. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fba43eb commit dc34fcd

File tree

3 files changed

+135
-57
lines changed

3 files changed

+135
-57
lines changed

src/sentry/integrations/msteams/webhook.py

Lines changed: 60 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
IntegrationProviderSlug,
4646
IntegrationResponse,
4747
)
48+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
4849
from sentry.models.activity import ActivityIntegration
4950
from sentry.models.apikey import ApiKey
5051
from sentry.models.group import Group
@@ -434,18 +435,19 @@ def _handle_team_member_removed(self, request: Request) -> Response:
434435
)
435436
if len(org_integrations) > 0:
436437
for org_integration in org_integrations:
437-
create_audit_entry(
438-
request=request,
439-
organization_id=org_integration.organization_id,
440-
target_object=integration.id,
441-
event=audit_log.get_event_id("INTEGRATION_REMOVE"),
442-
actor_label="Teams User",
443-
data={
444-
"provider": integration.provider,
445-
"name": integration.name,
446-
"team_id": team_id,
447-
},
448-
)
438+
with webhook_viewer_context(org_integration.organization_id):
439+
create_audit_entry(
440+
request=request,
441+
organization_id=org_integration.organization_id,
442+
target_object=integration.id,
443+
event=audit_log.get_event_id("INTEGRATION_REMOVE"),
444+
actor_label="Teams User",
445+
data={
446+
"provider": integration.provider,
447+
"name": integration.name,
448+
"team_id": team_id,
449+
},
450+
)
449451

450452
integration_service.delete_integration(integration_id=integration.id)
451453
return self.respond(status=204)
@@ -584,60 +586,61 @@ def _handle_action_submitted(self, request: Request) -> Response:
584586
)
585587
return self.respond(status=404)
586588

587-
idp = identity_service.get_provider(
588-
provider_type=IntegrationProviderSlug.MSTEAMS.value, provider_ext_id=team_id
589-
)
590-
if idp is None:
591-
logger.info(
592-
"msteams.action.invalid-team-id",
593-
extra={
594-
"team_id": team_id,
595-
"integration_id": integration.id,
596-
"organization_id": group.organization.id,
597-
},
589+
with webhook_viewer_context(group.organization.id):
590+
idp = identity_service.get_provider(
591+
provider_type=IntegrationProviderSlug.MSTEAMS.value, provider_ext_id=team_id
598592
)
599-
return self.respond(status=404)
593+
if idp is None:
594+
logger.info(
595+
"msteams.action.invalid-team-id",
596+
extra={
597+
"team_id": team_id,
598+
"integration_id": integration.id,
599+
"organization_id": group.organization.id,
600+
},
601+
)
602+
return self.respond(status=404)
600603

601-
identity = identity_service.get_identity(
602-
filter={"provider_id": idp.id, "identity_ext_id": user_id}
603-
)
604-
if identity is None:
605-
associate_url = build_linking_url(
606-
integration, group.organization, user_id, team_id, tenant_id
604+
identity = identity_service.get_identity(
605+
filter={"provider_id": idp.id, "identity_ext_id": user_id}
607606
)
607+
if identity is None:
608+
associate_url = build_linking_url(
609+
integration, group.organization, user_id, team_id, tenant_id
610+
)
608611

609-
card = build_linking_card(associate_url)
610-
user_conversation_id = client.get_user_conversation_id(user_id, tenant_id)
611-
client.send_card(user_conversation_id, card)
612-
return self.respond(status=201)
612+
card = build_linking_card(associate_url)
613+
user_conversation_id = client.get_user_conversation_id(user_id, tenant_id)
614+
client.send_card(user_conversation_id, card)
615+
return self.respond(status=201)
613616

614-
# update the state of the issue
615-
issue_change_response = self._issue_state_change(group, identity, data["value"])
617+
# update the state of the issue
618+
issue_change_response = self._issue_state_change(group, identity, data["value"])
616619

617-
# get the rules from the payload
618-
rules = tuple(Rule.objects.filter(id__in=payload["rules"]))
620+
# get the rules from the payload
621+
rules = tuple(Rule.objects.filter(id__in=payload["rules"]))
619622

620-
# pull the event based off our payload
621-
event = eventstore.backend.get_event_by_id(group.project_id, payload["eventId"])
622-
if event is None:
623-
logger.info(
624-
"msteams.action.event-missing",
625-
extra={
626-
"team_id": team_id,
627-
"integration_id": integration.id,
628-
"organization_id": group.organization.id,
629-
"event_id": payload["eventId"],
630-
"project_id": group.project_id,
631-
},
632-
)
633-
return self.respond(status=404)
623+
# pull the event based off our payload
624+
event = eventstore.backend.get_event_by_id(group.project_id, payload["eventId"])
625+
if event is None:
626+
logger.info(
627+
"msteams.action.event-missing",
628+
extra={
629+
"team_id": team_id,
630+
"integration_id": integration.id,
631+
"organization_id": group.organization.id,
632+
"event_id": payload["eventId"],
633+
"project_id": group.project_id,
634+
},
635+
)
636+
return self.respond(status=404)
634637

635-
# refresh issue and update card
636-
group.refresh_from_db()
637-
card = MSTeamsIssueMessageBuilder(group, event, rules, integration).build_group_card()
638-
client.update_card(conversation_id, activity_id, card)
638+
# refresh issue and update card
639+
group.refresh_from_db()
640+
card = MSTeamsIssueMessageBuilder(group, event, rules, integration).build_group_card()
641+
client.update_card(conversation_id, activity_id, card)
639642

640-
return issue_change_response
643+
return issue_change_response
641644

642645
def _handle_channel_message(self, request: Request) -> Response:
643646
data = request.data

tests/sentry/integrations/msteams/test_action_state_change.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import time
2+
from typing import Any
23
from unittest.mock import MagicMock, patch
34

45
import orjson
@@ -22,6 +23,7 @@
2223
from sentry.silo.base import SiloMode
2324
from sentry.testutils.asserts import assert_mock_called_once_with_partial, assert_slo_metric
2425
from sentry.testutils.cases import APITestCase
26+
from sentry.testutils.helpers.options import override_options
2527
from sentry.testutils.silo import assume_test_silo_mode
2628
from sentry.testutils.skips import requires_snuba
2729
from sentry.users.models.identity import Identity, IdentityStatus
@@ -359,6 +361,31 @@ def test_resolve_with_params(self, verify: MagicMock, client_put: MagicMock) ->
359361

360362
assert_mock_called_once_with_partial(client_put, data=expected_data)
361363

364+
@responses.activate
365+
@override_options({"viewer-context.enabled": True})
366+
@patch("sentry.integrations.msteams.webhook.verify_signature", return_value=True)
367+
def test_action_submitted_sets_viewer_context(self, verify: MagicMock) -> None:
368+
"""ViewerContext is set with org_id and actor_type=INTEGRATION during action handling."""
369+
from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context
370+
371+
captured_contexts: list[ViewerContext | None] = []
372+
373+
original_refresh = Group.refresh_from_db
374+
375+
def capturing_refresh(self_group: Any, *args: Any, **kwargs: Any) -> None:
376+
captured_contexts.append(get_viewer_context())
377+
return original_refresh(self_group, *args, **kwargs)
378+
379+
with patch.object(Group, "refresh_from_db", capturing_refresh):
380+
resp = self.post_webhook(action_type=ACTION_TYPE.RESOLVE, resolve_input="resolved")
381+
382+
assert resp.status_code == 200
383+
assert len(captured_contexts) == 1
384+
ctx = captured_contexts[0]
385+
assert ctx is not None
386+
assert ctx.organization_id == self.org.id
387+
assert ctx.actor_type == ActorType.INTEGRATION
388+
362389
@responses.activate
363390
@patch("sentry.integrations.msteams.webhook.verify_signature", return_value=True)
364391
def test_no_integration(self, verify: MagicMock) -> None:

tests/sentry/integrations/msteams/test_webhook.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sentry.silo.base import SiloMode
1616
from sentry.testutils.asserts import assert_slo_metric
1717
from sentry.testutils.cases import APITestCase
18+
from sentry.testutils.helpers.options import override_options
1819
from sentry.testutils.silo import assume_test_silo_mode
1920
from sentry.users.models.identity import Identity
2021
from sentry.utils import jwt
@@ -608,3 +609,50 @@ def test_invalid_silo_card_action_payload(
608609
HTTP_AUTHORIZATION=f"Bearer {TOKEN}",
609610
)
610611
assert response.status_code == 400
612+
613+
@responses.activate
614+
@override_options({"viewer-context.enabled": True})
615+
@mock.patch("sentry.utils.jwt.decode")
616+
@mock.patch("time.time")
617+
def test_member_removed_sets_viewer_context(
618+
self, mock_time: MagicMock, mock_decode: MagicMock
619+
) -> None:
620+
"""ViewerContext is set with org_id and actor_type=INTEGRATION during member removal."""
621+
from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context
622+
623+
with assume_test_silo_mode(SiloMode.CONTROL):
624+
integration = self.create_provider_integration(external_id=team_id, provider="msteams")
625+
self.create_organization_integration(
626+
organization_id=self.organization.id, integration=integration
627+
)
628+
629+
captured_contexts: list[ViewerContext | None] = []
630+
631+
original_create_audit_entry = __import__(
632+
"sentry.utils.audit", fromlist=["create_audit_entry"]
633+
).create_audit_entry
634+
635+
def capturing_create_audit_entry(*args: object, **kwargs: object) -> object:
636+
captured_contexts.append(get_viewer_context())
637+
return original_create_audit_entry(*args, **kwargs)
638+
639+
mock_time.return_value = 1594839999 + 60
640+
mock_decode.return_value = DECODED_TOKEN
641+
642+
with mock.patch(
643+
"sentry.integrations.msteams.webhook.create_audit_entry",
644+
side_effect=capturing_create_audit_entry,
645+
):
646+
resp = self.client.post(
647+
path=webhook_url,
648+
data=EXAMPLE_TEAM_MEMBER_REMOVED,
649+
format="json",
650+
HTTP_AUTHORIZATION=f"Bearer {TOKEN}",
651+
)
652+
653+
assert resp.status_code == 204
654+
assert len(captured_contexts) == 1
655+
ctx = captured_contexts[0]
656+
assert ctx is not None
657+
assert ctx.organization_id == self.organization.id
658+
assert ctx.actor_type == ActorType.INTEGRATION

0 commit comments

Comments
 (0)