Skip to content

Commit b77a4bb

Browse files
authored
feat(opsgenie): refactor validation (#53996)
Refactor key validation to allow all types of integration keys. Check that keys are valid upon alert rule save/update. Fixes ER-1772 Fixes ER-1773 Fixes ER-1774
1 parent 1c746e7 commit b77a4bb

File tree

9 files changed

+218
-48
lines changed

9 files changed

+218
-48
lines changed

src/sentry/incidents/logic.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from sentry.services.hybrid_cloud.app import RpcSentryAppInstallation, app_service
4343
from sentry.services.hybrid_cloud.integration import RpcIntegration, integration_service
4444
from sentry.services.hybrid_cloud.integration.model import RpcOrganizationIntegration
45-
from sentry.shared_integrations.exceptions import DuplicateDisplayNameError
45+
from sentry.shared_integrations.exceptions import ApiError, DuplicateDisplayNameError
4646
from sentry.snuba.dataset import Dataset
4747
from sentry.snuba.entity_subscription import (
4848
ENTITY_TIME_COLUMNS,
@@ -1339,7 +1339,8 @@ def get_alert_rule_trigger_action_opsgenie_team(
13391339
use_async_lookup=False,
13401340
input_channel_id=None,
13411341
integrations=None,
1342-
):
1342+
) -> tuple[str, str]:
1343+
from sentry.integrations.opsgenie.client import OpsgenieClient
13431344
from sentry.integrations.opsgenie.utils import get_team
13441345

13451346
oi = integration_service.get_organization_integration(
@@ -1348,6 +1349,20 @@ def get_alert_rule_trigger_action_opsgenie_team(
13481349
team = get_team(target_value, oi)
13491350
if not team:
13501351
raise InvalidTriggerActionError("No Opsgenie team found.")
1352+
1353+
integration_key = team["integration_key"]
1354+
integration = integration_service.get_integration(integration_id=integration_id)
1355+
if integration is None:
1356+
raise InvalidTriggerActionError("Opsgenie integration not found.")
1357+
client = OpsgenieClient(
1358+
integration=integration,
1359+
integration_key=integration_key,
1360+
org_integration_id=oi.id,
1361+
)
1362+
try:
1363+
client.authorize_integration(type="sentry")
1364+
except ApiError:
1365+
raise InvalidTriggerActionError("Invalid integration key.")
13511366
return team["id"], team["team"]
13521367

13531368

src/sentry/integrations/opsgenie/actions/form.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55
from django import forms
66
from django.utils.translation import gettext_lazy as _
77

8+
from sentry.integrations.opsgenie.client import OpsgenieClient
89
from sentry.integrations.opsgenie.utils import get_team
910
from sentry.services.hybrid_cloud.integration import integration_service
10-
from sentry.services.hybrid_cloud.integration.model import RpcOrganizationIntegration
11+
from sentry.services.hybrid_cloud.integration.model import (
12+
RpcIntegration,
13+
RpcOrganizationIntegration,
14+
)
15+
from sentry.shared_integrations.exceptions import ApiError
16+
17+
INVALID_TEAM = 1
18+
INVALID_KEY = 2
19+
VALID_TEAM = 3
1120

1221

1322
def _validate_int_field(field: str, cleaned_data: Mapping[str, Any]) -> int | None:
@@ -46,25 +55,67 @@ def __init__(self, *args, **kwargs):
4655
self.fields["team"].choices = teams
4756
self.fields["team"].widget.choices = self.fields["team"].choices
4857

49-
def _team_is_valid(
50-
self, team_id: str | None, org_integration: RpcOrganizationIntegration | None
51-
) -> bool:
52-
return True if get_team(team_id, org_integration) is not None else False
58+
def _get_team_status(
59+
self,
60+
team_id: str | None,
61+
integration: RpcIntegration,
62+
org_integration: RpcOrganizationIntegration,
63+
) -> int:
64+
team = get_team(team_id, org_integration)
65+
if not team:
66+
return INVALID_TEAM
67+
68+
integration_key = team["integration_key"]
69+
client = OpsgenieClient(
70+
integration=integration,
71+
integration_key=integration_key,
72+
org_integration_id=org_integration.id,
73+
)
74+
# the integration should be of type "sentry"
75+
# there's no way to authenticate that a key is an integration key
76+
# without specifying the type... even though the type is arbitrary
77+
# and all integration keys do the same thing
78+
try:
79+
client.authorize_integration(type="sentry")
80+
except ApiError:
81+
return INVALID_KEY
82+
83+
return VALID_TEAM
5384

5485
def _validate_team(self, team_id: str | None, integration_id: int | None) -> None:
5586
params = {
5687
"account": dict(self.fields["account"].choices).get(integration_id),
5788
"team": dict(self.fields["team"].choices).get(team_id),
5889
}
90+
integration = integration_service.get_integration(
91+
integration_id=integration_id, provider="opsgenie"
92+
)
5993
org_integration = integration_service.get_organization_integration(
6094
integration_id=integration_id,
6195
organization_id=self.org_id,
6296
)
63-
64-
if not self._team_is_valid(team_id=team_id, org_integration=org_integration):
97+
if integration is None or org_integration is None:
98+
raise forms.ValidationError(
99+
_("The Opsgenie integration does not exist."),
100+
code="invalid_integration",
101+
params=params,
102+
)
103+
team_status = self._get_team_status(
104+
team_id=team_id, integration=integration, org_integration=org_integration
105+
)
106+
if team_status == INVALID_TEAM:
65107
raise forms.ValidationError(
66108
_('The team "%(team)s" does not belong to the %(account)s Opsgenie account.'),
67-
code="invalid",
109+
code="invalid_team",
110+
params=params,
111+
)
112+
elif team_status == INVALID_KEY:
113+
raise forms.ValidationError(
114+
_(
115+
'The provided API key is invalid. Please make sure that the Opsgenie API \
116+
key is an integration key of type "Sentry".'
117+
),
118+
code="invalid_key",
68119
params=params,
69120
)
70121

src/sentry/integrations/opsgenie/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ def get_team_id(self, team_name: str) -> BaseApiResponseX:
5858
headers = {"Authorization": "GenieKey " + self.integration_key}
5959
return self.get(path=path, headers=headers, params=params)
6060

61+
def authorize_integration(self, type: str) -> BaseApiResponseX:
62+
body = {"type": type}
63+
path = "/integrations/authenticate"
64+
headers = {"Authorization": "GenieKey " + self.integration_key}
65+
return self.post(path=path, headers=headers, data=body)
66+
6167
def _get_rule_urls(self, group, rules):
6268
organization = group.project.organization
6369
rule_urls = []

src/sentry/integrations/opsgenie/integration.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,31 +134,35 @@ def get_organization_config(self) -> Sequence[Any]:
134134
{
135135
"name": "team_table",
136136
"type": "table",
137-
"label": "Opsgenie teams with the Sentry integration enabled",
138-
"help": "If teams need to be updated, deleted, or added manually please do so here. Alert rules will need to be individually updated for any additions or deletions of teams.",
137+
"label": "Opsgenie integrations",
138+
"help": "If integration keys need to be updated, deleted, or added manually please do so here. Your keys must be associated with a 'Sentry' Integration in Opsgenie. \
139+
Alert rules will need to be individually updated for any key additions or deletions.",
139140
"addButtonText": "",
140-
"columnLabels": {"team": "Opsgenie Team", "integration_key": "Integration Key"},
141+
"columnLabels": {
142+
"team": "Opsgenie Integration",
143+
"integration_key": "Integration Key",
144+
},
141145
"columnKeys": ["team", "integration_key"],
142-
"confirmDeleteMessage": "Any alert rules associated with this team will stop working. The rules will still exist but will show a `removed` team.",
146+
"confirmDeleteMessage": "Any alert rules associated with this integration will stop working. The rules will still exist but will show a `removed` team.",
143147
}
144148
]
145149

146150
return fields
147151

148152
def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
149-
# get the team ID/test the API key for a newly added row
153+
# add the integration ID to a newly added row
154+
if not self.org_integration:
155+
return
156+
150157
teams = data["team_table"]
151158
unsaved_teams = [team for team in teams if team["id"] == ""]
159+
# this is not instantaneous, so you could add the same team a bunch of times in a row
160+
# but I don't anticipate this being too much of an issue
161+
added_names = {team["team"] for team in teams if team not in unsaved_teams}
152162
for team in unsaved_teams:
153-
try:
154-
client = self.get_client(integration_key=team["integration_key"])
155-
resp = client.get_team_id(team_name=team["team"])
156-
team["id"] = resp["data"]["id"]
157-
except ApiError:
158-
raise ValidationError(
159-
{"api_key": ["Could not save due to invalid team name or integration key."]}
160-
)
161-
163+
if team["team"] in added_names:
164+
raise ValidationError({"duplicate_name": ["Duplicate team name."]})
165+
team["id"] = str(self.org_integration.id) + "-" + team["team"]
162166
return super().update_organization_config(data)
163167

164168

tests/sentry/incidents/action_handlers/test_opsgenie.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
@freeze_time()
2323
class OpsgenieActionHandlerTest(FireTest, TestCase):
24+
@responses.activate
2425
def setUp(self):
2526
self.og_team = {"id": "123-id", "team": "cool-team", "integration_key": "1234-5678"}
2627
self.integration = Integration.objects.create(
@@ -33,13 +34,25 @@ def setUp(self):
3334
self.org_integration.config = {"team_table": [self.og_team]}
3435
self.org_integration.save()
3536

37+
resp_data = {
38+
"result": "Integration [sentry] is valid",
39+
"took": 1,
40+
"requestId": "hello-world",
41+
}
42+
responses.add(
43+
responses.POST,
44+
url="https://api.opsgenie.com/v2/integrations/authenticate",
45+
json=resp_data,
46+
)
47+
3648
self.action = self.create_alert_rule_trigger_action(
3749
target_identifier=self.og_team["id"],
3850
type=AlertRuleTriggerAction.Type.OPSGENIE,
3951
target_type=AlertRuleTriggerAction.TargetType.SPECIFIC,
4052
integration=self.integration,
4153
)
4254

55+
@responses.activate
4356
def test_build_incident_attachment(self):
4457
from sentry.integrations.opsgenie.utils import build_incident_attachment
4558

@@ -48,6 +61,16 @@ def test_build_incident_attachment(self):
4861
update_incident_status(
4962
incident, IncidentStatus.CRITICAL, status_method=IncidentStatusMethod.RULE_TRIGGERED
5063
)
64+
resp_data = {
65+
"result": "Integration [sentry] is valid",
66+
"took": 1,
67+
"requestId": "hello-world",
68+
}
69+
responses.add(
70+
responses.POST,
71+
url="https://api.opsgenie.com/v2/integrations/authenticate",
72+
json=resp_data,
73+
)
5174
self.create_alert_rule_trigger_action(
5275
target_identifier=self.og_team["id"],
5376
type=AlertRuleTriggerAction.Type.OPSGENIE,
@@ -88,16 +111,19 @@ def run_test(self, incident, method):
88111
incident, IncidentStatus(incident.status), metric_value
89112
)
90113

114+
@responses.activate
91115
def test_fire_metric_alert(self):
92116
self.run_fire_test()
93117

118+
@responses.activate
94119
def test_fire_metric_alert_multiple_teams(self):
95120
team2 = {"id": "456-id", "team": "cooler-team", "integration_key": "1234-7890"}
96121
self.org_integration.config["team_table"].append(team2)
97122
self.org_integration.save()
98123

99124
self.run_fire_test()
100125

126+
@responses.activate
101127
def test_resolve_metric_alert(self):
102128
self.run_fire_test("resolve")
103129

@@ -120,6 +146,7 @@ def test_rule_snoozed(self):
120146

121147
assert len(responses.calls) == 0
122148

149+
@responses.activate
123150
@patch("sentry.integrations.opsgenie.utils.logger")
124151
def test_missing_integration(self, mock_logger):
125152
alert_rule = self.create_alert_rule()
@@ -138,6 +165,7 @@ def test_missing_integration(self, mock_logger):
138165
== "Opsgenie integration removed, but the rule is still active."
139166
)
140167

168+
@responses.activate
141169
@patch("sentry.integrations.opsgenie.utils.logger")
142170
def test_missing_team(self, mock_logger):
143171
alert_rule = self.create_alert_rule()

tests/sentry/incidents/test_logic.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,7 @@ def test_pagerduty_not_existing(self):
16301630
integration_id=integration.id,
16311631
)
16321632

1633+
@responses.activate
16331634
def test_opsgenie(self):
16341635
metadata = {
16351636
"api_key": "1234-ABCD",
@@ -1647,6 +1648,17 @@ def test_opsgenie(self):
16471648
org_integration.config = {"team_table": [team]}
16481649
org_integration.save()
16491650

1651+
resp_data = {
1652+
"result": "Integration [sentry] is valid",
1653+
"took": 1,
1654+
"requestId": "hello-world",
1655+
}
1656+
responses.add(
1657+
responses.POST,
1658+
url="https://api.opsgenie.com/v2/integrations/authenticate",
1659+
json=resp_data,
1660+
)
1661+
16501662
type = AlertRuleTriggerAction.Type.OPSGENIE
16511663
target_type = AlertRuleTriggerAction.TargetType.SPECIFIC
16521664
action = update_alert_rule_trigger_action(

tests/sentry/integrations/opsgenie/test_client.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import responses
22

3-
from sentry.models import Integration, OrganizationIntegration, Rule
3+
from sentry.models import Integration, Rule
44
from sentry.testutils.cases import APITestCase
55
from sentry.utils import json
66

@@ -32,28 +32,27 @@ def test_get_client(self):
3232
assert client.integration_key == METADATA["api_key"]
3333

3434
@responses.activate
35-
def test_get_team_id(self):
35+
def test_authorize_integration(self):
3636
client = self.installation.get_client(integration_key="1234-5678")
3737

38-
org_integration = OrganizationIntegration.objects.get(
39-
organization_id=self.organization.id, integration_id=self.integration.id
40-
)
41-
org_integration.config = {
42-
"team_table": [{"id": "", "team": "cool-team", "integration_key": "1234-5678"}]
38+
resp_data = {
39+
"result": "Integration [sentry] is valid",
40+
"took": 1,
41+
"requestId": "hello-world",
4342
}
44-
org_integration.save()
45-
resp_data = {"data": {"id": "123-id", "name": "cool-team"}}
46-
responses.add(responses.GET, url=f"{client.base_url}/teams/cool-team", json=resp_data)
43+
responses.add(
44+
responses.POST, url=f"{client.base_url}/integrations/authenticate", json=resp_data
45+
)
4746

48-
resp = client.get_team_id(team_name="cool-team")
47+
resp = client.authorize_integration(type="sentry")
4948
assert resp == resp_data
5049

5150
@responses.activate
5251
def test_send_notification(self):
5352
resp_data = {
5453
"result": "Request will be processed",
55-
"took": 0.302,
56-
"requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120",
54+
"took": 1,
55+
"requestId": "hello-world",
5756
}
5857
responses.add(responses.POST, url="https://api.opsgenie.com/v2/alerts", json=resp_data)
5958

0 commit comments

Comments
 (0)