Skip to content

Commit bd52d45

Browse files
authored
ref(opsgenie): verify integration key upon key save rather than upon alert rule save (#67081)
1 parent 25b08ee commit bd52d45

File tree

6 files changed

+129
-25
lines changed

6 files changed

+129
-25
lines changed

src/sentry/api/endpoints/integrations/organization_integrations/details.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from sentry.models.integrations.organization_integration import OrganizationIntegration
2222
from sentry.models.scheduledeletion import ScheduledDeletion
2323
from sentry.services.hybrid_cloud.organization import RpcUserOrganizationContext
24-
from sentry.shared_integrations.exceptions import IntegrationError
24+
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
2525
from sentry.utils.audit import create_audit_entry
2626
from sentry.web.decorators import set_referrer_policy
2727

@@ -118,7 +118,7 @@ def post(
118118
)
119119
try:
120120
installation.update_organization_config(request.data)
121-
except IntegrationError as e:
122-
return self.respond({"detail": str(e)}, status=400)
121+
except (IntegrationError, ApiError) as e:
122+
return self.respond({"detail": [str(e)]}, status=400)
123123

124124
return self.respond(status=200)

src/sentry/incidents/logic.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from copy import deepcopy
66
from dataclasses import replace
77
from datetime import datetime, timedelta, timezone
8-
from typing import Any, cast
8+
from typing import Any
99
from uuid import uuid4
1010

1111
from django.db import router, transaction
@@ -58,7 +58,6 @@
5858
from sentry.services.hybrid_cloud.integration import RpcIntegration, integration_service
5959
from sentry.services.hybrid_cloud.integration.model import RpcOrganizationIntegration
6060
from sentry.shared_integrations.exceptions import (
61-
ApiError,
6261
ApiTimeoutError,
6362
DuplicateDisplayNameError,
6463
IntegrationError,
@@ -1476,7 +1475,6 @@ def get_alert_rule_trigger_action_opsgenie_team(
14761475
input_channel_id=None,
14771476
integrations=None,
14781477
) -> tuple[str, str]:
1479-
from sentry.integrations.opsgenie.integration import OpsgenieIntegration
14801478
from sentry.integrations.opsgenie.utils import get_team
14811479

14821480
integration, oi = integration_service.get_organization_context(
@@ -1489,17 +1487,6 @@ def get_alert_rule_trigger_action_opsgenie_team(
14891487
if not team:
14901488
raise InvalidTriggerActionError("No Opsgenie team found.")
14911489

1492-
install = cast(
1493-
"OpsgenieIntegration",
1494-
integration.get_installation(organization_id=organization.id),
1495-
)
1496-
client = install.get_keyring_client(keyid=team["id"])
1497-
1498-
try:
1499-
client.authorize_integration(type="sentry")
1500-
except ApiError as e:
1501-
logger.info("opsgenie.authorization_error", extra={"error": str(e)})
1502-
raise InvalidTriggerActionError("Invalid integration key.")
15031490
return team["id"], team["team"]
15041491

15051492

src/sentry/integrations/opsgenie/client.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from typing import Literal
4-
from urllib.parse import quote
54

65
from sentry.eventstore.models import Event, GroupEvent
76
from sentry.integrations.client import ApiClient
@@ -33,13 +32,9 @@ def metadata(self):
3332
def _get_auth_headers(self):
3433
return {"Authorization": f"GenieKey {self.integration_key}"}
3534

36-
# This doesn't work if the team name is "." or "..", which Opsgenie allows for some reason
37-
# despite their API not working with these names.
38-
def get_team_id(self, team_name: str) -> BaseApiResponseX:
39-
params = {"identifierType": "name"}
40-
quoted_name = quote(team_name)
41-
path = f"/teams/{quoted_name}"
42-
return self.get(path=path, headers=self._get_auth_headers(), params=params)
35+
def get_alerts(self, limit: int | None = 1) -> BaseApiResponseX:
36+
path = f"/alerts?limit={limit}"
37+
return self.get(path=path, headers=self._get_auth_headers())
4338

4439
def authorize_integration(self, type: str) -> BaseApiResponseX:
4540
body = {"type": type}

src/sentry/integrations/opsgenie/integration.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
from sentry.models.integrations.organization_integration import OrganizationIntegration
2222
from sentry.pipeline import PipelineView
2323
from sentry.services.hybrid_cloud.organization import RpcOrganizationSummary
24+
from sentry.shared_integrations.exceptions import (
25+
ApiError,
26+
ApiRateLimitedError,
27+
ApiUnauthorized,
28+
IntegrationError,
29+
)
2430
from sentry.tasks.integrations import migrate_opsgenie_plugin
2531
from sentry.web.helpers import render_to_response
2632

@@ -147,6 +153,8 @@ def get_organization_config(self) -> Sequence[Any]:
147153
return fields
148154

149155
def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
156+
from sentry.services.hybrid_cloud.integration import integration_service
157+
150158
# add the integration ID to a newly added row
151159
if not self.org_integration:
152160
return
@@ -156,10 +164,57 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
156164
# this is not instantaneous, so you could add the same team a bunch of times in a row
157165
# but I don't anticipate this being too much of an issue
158166
added_names = {team["team"] for team in teams if team not in unsaved_teams}
167+
existing_team_key_pairs = {
168+
(team["team"], team["integration_key"]) for team in teams if team not in unsaved_teams
169+
}
170+
171+
integration = integration_service.get_integration(
172+
organization_integration_id=self.org_integration.id
173+
)
174+
if not integration:
175+
raise IntegrationError("Integration does not exist")
176+
159177
for team in unsaved_teams:
160178
if team["team"] in added_names:
161179
raise ValidationError({"duplicate_name": ["Duplicate team name."]})
162180
team["id"] = str(self.org_integration.id) + "-" + team["team"]
181+
182+
invalid_keys = []
183+
for team in teams:
184+
# skip if team, key pair already exist in config
185+
if (team["team"], team["integration_key"]) in existing_team_key_pairs:
186+
continue
187+
188+
integration_key = team["integration_key"]
189+
190+
# validate integration keys
191+
client = OpsgenieClient(
192+
integration=integration,
193+
integration_key=integration_key,
194+
)
195+
# call an API to test the integration key
196+
try:
197+
client.get_alerts()
198+
except ApiError as e:
199+
logger.info(
200+
"opsgenie.authorization_error",
201+
extra={"error": str(e), "status_code": e.code},
202+
)
203+
if e.code == 429:
204+
raise ApiRateLimitedError(
205+
"Too many requests. Please try updating one team/key at a time."
206+
)
207+
elif e.code == 401:
208+
invalid_keys.append(integration_key)
209+
pass
210+
elif e.json and e.json.get("message"):
211+
raise ApiError(e.json["message"])
212+
else:
213+
raise
214+
215+
if invalid_keys:
216+
raise ApiUnauthorized(f"Invalid integration key: {str(invalid_keys)}")
217+
163218
return super().update_organization_config(data)
164219

165220
def schedule_migrate_opsgenie_plugin(self):

tests/sentry/api/endpoints/test_organization_integration_details.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from unittest.mock import patch
2+
3+
from sentry.integrations.base import IntegrationInstallation
14
from sentry.models.identity import Identity
25
from sentry.models.integrations.integration import Integration
36
from sentry.models.integrations.organization_integration import OrganizationIntegration
47
from sentry.models.repository import Repository
58
from sentry.models.scheduledeletion import ScheduledDeletion
9+
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
610
from sentry.silo import SiloMode
711
from sentry.testutils.cases import APITestCase
812
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
@@ -58,6 +62,22 @@ def test_update_config(self):
5862

5963
assert org_integration.config == config
6064

65+
@patch.object(IntegrationInstallation, "update_organization_config")
66+
def test_update_config_error(self, mock_update_config):
67+
config = {"setting": "new_value", "setting2": "baz"}
68+
69+
mock_update_config.side_effect = IntegrationError("hello")
70+
response = self.get_error_response(
71+
self.organization.slug, self.integration.id, **config, status_code=400
72+
)
73+
assert response.data["detail"] == ["hello"]
74+
75+
mock_update_config.side_effect = ApiError("hi")
76+
response = self.get_error_response(
77+
self.organization.slug, self.integration.id, **config, status_code=400
78+
)
79+
assert response.data["detail"] == ["hi"]
80+
6181

6282
@control_silo_test
6383
class OrganizationIntegrationDetailsDeleteTest(OrganizationIntegrationDetailsTest):

tests/sentry/integrations/opsgenie/test_integration.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sentry.models.integrations.integration import Integration
1010
from sentry.models.integrations.organization_integration import OrganizationIntegration
1111
from sentry.models.rule import Rule
12+
from sentry.shared_integrations.exceptions import ApiRateLimitedError, ApiUnauthorized
1213
from sentry.tasks.integrations.migrate_opsgenie_plugins import (
1314
ALERT_LEGACY_INTEGRATIONS,
1415
ALERT_LEGACY_INTEGRATIONS_WITH_NAME,
@@ -134,6 +135,10 @@ def test_update_config_valid(self):
134135
integration = Integration.objects.get(provider=self.provider.key)
135136
org_integration = OrganizationIntegration.objects.get(integration_id=integration.id)
136137

138+
responses.add(
139+
responses.GET, url="https://api.opsgenie.com/v2/alerts?limit=1", status=200, json={}
140+
)
141+
137142
data = {"team_table": [{"id": "", "team": "cool-team", "integration_key": "1234-5678"}]}
138143
installation.update_organization_config(data)
139144
team_id = str(org_integration.id) + "-" + "cool-team"
@@ -153,6 +158,10 @@ def test_update_config_invalid(self):
153158
org_integration = OrganizationIntegration.objects.get(integration_id=integration.id)
154159
team_id = str(org_integration.id) + "-" + "cool-team"
155160

161+
responses.add(
162+
responses.GET, url="https://api.opsgenie.com/v2/alerts?limit=1", status=200, json={}
163+
)
164+
156165
# valid
157166
data = {"team_table": [{"id": "", "team": "cool-team", "integration_key": "1234"}]}
158167
installation.update_organization_config(data)
@@ -173,6 +182,44 @@ def test_update_config_invalid(self):
173182
"team_table": [{"id": team_id, "team": "cool-team", "integration_key": "1234"}]
174183
}
175184

185+
@responses.activate
186+
def test_update_config_invalid_rate_limited(self):
187+
integration = self.create_provider_integration(
188+
provider="opsgenie", name="test-app", external_id=EXTERNAL_ID, metadata=METADATA
189+
)
190+
integration.add_organization(self.organization, self.user)
191+
installation = integration.get_installation(self.organization.id)
192+
193+
data = {
194+
"team_table": [
195+
{"id": "", "team": "rad-team", "integration_key": "4321"},
196+
{"id": "cool-team", "team": "cool-team", "integration_key": "1234"},
197+
]
198+
}
199+
responses.add(responses.GET, url="https://api.opsgenie.com/v2/alerts?limit=1", status=429)
200+
201+
with pytest.raises(ApiRateLimitedError):
202+
installation.update_organization_config(data)
203+
204+
@responses.activate
205+
def test_update_config_invalid_integration_key(self):
206+
integration = self.create_provider_integration(
207+
provider="opsgenie", name="test-app", external_id=EXTERNAL_ID, metadata=METADATA
208+
)
209+
integration.add_organization(self.organization, self.user)
210+
installation = integration.get_installation(self.organization.id)
211+
212+
data = {
213+
"team_table": [
214+
{"id": "cool-team", "team": "cool-team", "integration_key": "1234"},
215+
{"id": "", "team": "rad-team", "integration_key": "4321"},
216+
]
217+
}
218+
responses.add(responses.GET, url="https://api.opsgenie.com/v2/alerts?limit=1", status=401)
219+
220+
with pytest.raises(ApiUnauthorized):
221+
installation.update_organization_config(data)
222+
176223

177224
@region_silo_test
178225
class OpsgenieMigrationIntegrationTest(APITestCase):

0 commit comments

Comments
 (0)