Skip to content

Commit 7b60f8b

Browse files
authored
feat(tempest): Endpoint for fetching tempest credentials (#82222)
Endpoint only for GET request. Responsible for fetching all tempest credentials for a given project. Part of getsentry/team-gdx#52 2 more endpoints will be done as a part of separate PRs to reduce the scope of the PR: - POST - creation of new credentials record - DELETE - deletion of the existing credentials record --------- Signed-off-by: Vjeran Grozdanic <[email protected]>
1 parent ca978f3 commit 7b60f8b

File tree

9 files changed

+159
-0
lines changed

9 files changed

+159
-0
lines changed

.github/CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,7 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge
632632
# Taskworkers
633633
/src/sentry/taskworker/ @getsentry/taskworker
634634
/tests/sentry/taskworker/ @getsentry/taskworker
635+
636+
# Tempest
637+
/src/sentry/tempest/ @getsentry/gdx
638+
/tests/sentry/tempest/ @getsentry/gdx

src/sentry/api/api_owners.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ class ApiOwner(Enum):
2828
TELEMETRY_EXPERIENCE = "telemetry-experience"
2929
UNOWNED = "unowned"
3030
WEB_FRONTEND_SDKS = "team-web-sdk-frontend"
31+
GDX = "gdx"

src/sentry/api/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@
313313
from sentry.sentry_apps.api.endpoints.sentry_internal_app_tokens import (
314314
SentryInternalAppTokensEndpoint,
315315
)
316+
from sentry.tempest.endpoints.tempest_credentials import TempestCredentialsEndpoint
316317
from sentry.uptime.endpoints.project_uptime_alert_details import ProjectUptimeAlertDetailsEndpoint
317318
from sentry.uptime.endpoints.project_uptime_alert_index import ProjectUptimeAlertIndexEndpoint
318319
from sentry.users.api.endpoints.authenticator_index import AuthenticatorIndexEndpoint
@@ -2787,6 +2788,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
27872788
ProjectUptimeAlertIndexEndpoint.as_view(),
27882789
name="sentry-api-0-project-uptime-alert-index",
27892790
),
2791+
# Tempest
2792+
re_path(
2793+
r"^(?P<organization_id_or_slug>[^\/]+)/(?P<project_id_or_slug>[^\/]+)/tempest-credentials/$",
2794+
TempestCredentialsEndpoint.as_view(),
2795+
name="sentry-api-0-project-tempest-credentials",
2796+
),
27902797
*workflow_urls.urlpatterns,
27912798
]
27922799

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from rest_framework.exceptions import NotFound
2+
from rest_framework.request import Request
3+
from rest_framework.response import Response
4+
5+
from sentry import features
6+
from sentry.api.api_owners import ApiOwner
7+
from sentry.api.api_publish_status import ApiPublishStatus
8+
from sentry.api.base import region_silo_endpoint
9+
from sentry.api.bases import ProjectEndpoint
10+
from sentry.api.paginator import OffsetPaginator
11+
from sentry.api.serializers.base import serialize
12+
from sentry.models.project import Project
13+
from sentry.tempest.models import TempestCredentials
14+
from sentry.tempest.permissions import TempestCredentialsPermission
15+
from sentry.tempest.serializers import TempestCredentialsSerializer
16+
17+
18+
@region_silo_endpoint
19+
class TempestCredentialsEndpoint(ProjectEndpoint):
20+
publish_status = {
21+
"GET": ApiPublishStatus.PRIVATE,
22+
}
23+
owner = ApiOwner.GDX
24+
25+
permission_classes = (TempestCredentialsPermission,)
26+
27+
def has_feature(self, request: Request, project: Project) -> bool:
28+
return features.has(
29+
"organizations:tempest-access", project.organization, actor=request.user
30+
)
31+
32+
def get(self, request: Request, project: Project) -> Response:
33+
if not self.has_feature(request, project):
34+
raise NotFound
35+
36+
tempest_credentials_qs = TempestCredentials.objects.filter(project=project)
37+
return self.paginate(
38+
request=request,
39+
queryset=tempest_credentials_qs,
40+
on_results=lambda x: serialize(x, request.user, TempestCredentialsSerializer()),
41+
paginator_cls=OffsetPaginator,
42+
)

src/sentry/tempest/permissions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from sentry.api.bases.project import ProjectPermission
2+
3+
4+
class TempestCredentialsPermission(ProjectPermission):
5+
scope_map = {
6+
"GET": [
7+
"project:read",
8+
"project:write",
9+
"project:admin",
10+
"org:read",
11+
"org:write",
12+
"org:admin",
13+
],
14+
"POST": ["org:admin"],
15+
"DELETE": ["org:admin"],
16+
}

src/sentry/tempest/serializers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from sentry.api.serializers.base import Serializer, register
2+
from sentry.tempest.models import TempestCredentials
3+
4+
5+
@register(TempestCredentials)
6+
class TempestCredentialsSerializer(Serializer):
7+
def _obfuscate_client_secret(self, client_secret: str) -> str:
8+
return "*" * len(client_secret)
9+
10+
def serialize(self, obj, attrs, user, **kwargs):
11+
return {
12+
"id": obj.id,
13+
"clientId": obj.client_id,
14+
"clientSecret": self._obfuscate_client_secret(obj.client_secret),
15+
"message": obj.message,
16+
"messageType": obj.message_type,
17+
"latestFetchedItemId": obj.latest_fetched_item_id,
18+
"createdById": obj.created_by_id,
19+
}

src/sentry/testutils/factories.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@
146146
from sentry.silo.base import SiloMode
147147
from sentry.snuba.dataset import Dataset
148148
from sentry.snuba.models import QuerySubscription, QuerySubscriptionDataSourceHandler
149+
from sentry.tempest.models import MessageType as TempestMessageType
150+
from sentry.tempest.models import TempestCredentials
149151
from sentry.testutils.outbox import outbox_runner
150152
from sentry.testutils.silo import assume_test_silo_mode
151153
from sentry.types.activity import ActivityType
@@ -604,6 +606,34 @@ def create_slack_project_rule(project, integration_id, channel_id=None, channel_
604606
def create_project_key(project):
605607
return project.key_set.get_or_create()[0]
606608

609+
@staticmethod
610+
@assume_test_silo_mode(SiloMode.REGION)
611+
def create_tempest_credentials(
612+
project: Project,
613+
created_by: User | None = None,
614+
client_id: str | None = None,
615+
client_secret: str | None = None,
616+
message: str = "",
617+
message_type: str | None = None,
618+
latest_fetched_item_id: str | None = None,
619+
):
620+
if client_id is None:
621+
client_id = str(uuid4())
622+
if client_secret is None:
623+
client_secret = str(uuid4())
624+
if message_type is None:
625+
message_type = TempestMessageType.ERROR
626+
627+
return TempestCredentials.objects.create(
628+
project=project,
629+
created_by_id=created_by.id if created_by else None,
630+
client_id=client_id,
631+
client_secret=client_secret,
632+
message=message,
633+
message_type=message_type,
634+
latest_fetched_item_id=latest_fetched_item_id,
635+
)
636+
607637
@staticmethod
608638
@assume_test_silo_mode(SiloMode.REGION)
609639
def create_release(

src/sentry/testutils/fixtures.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from sentry.organizations.services.organization import RpcOrganization
2828
from sentry.silo.base import SiloMode
2929
from sentry.snuba.models import QuerySubscription
30+
from sentry.tempest.models import TempestCredentials
3031
from sentry.testutils.factories import Factories
3132
from sentry.testutils.helpers.datetime import before_now, iso_format
3233
from sentry.testutils.silo import assume_test_silo_mode
@@ -280,6 +281,9 @@ def create_usersocialauth(
280281
def store_event(self, *args, **kwargs) -> Event:
281282
return Factories.store_event(*args, **kwargs)
282283

284+
def create_tempest_credentials(self, project: Project, *args, **kwargs) -> TempestCredentials:
285+
return Factories.create_tempest_credentials(project, *args, **kwargs)
286+
283287
def create_group(self, project=None, *args, **kwargs):
284288
if project is None:
285289
project = self.project
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from sentry.testutils.cases import APITestCase
2+
from sentry.testutils.helpers.features import Feature
3+
4+
5+
class TestTempestCredentials(APITestCase):
6+
endpoint = "sentry-api-0-project-tempest-credentials"
7+
8+
def test_create_tempest_credentials(self):
9+
with Feature({"organizations:tempest-access": True}):
10+
credentials = [self.create_tempest_credentials(self.project) for _ in range(5)]
11+
12+
other_project = self.create_project(organization=self.organization)
13+
# credentials connected to other project which should not be included in the response
14+
for _ in range(5):
15+
self.create_tempest_credentials(other_project)
16+
17+
self.login_as(self.user)
18+
response = self.get_success_response(self.project.organization.slug, self.project.slug)
19+
20+
assert len(response.data) == 5
21+
assert {cred.id for cred in credentials} == {item["id"] for item in response.data}
22+
23+
def test_endpoint_returns_404_if_feature_flag_is_disabled(self):
24+
self.login_as(self.user)
25+
response = self.get_response(self.project.organization.slug, self.project.slug)
26+
assert response.status_code == 404
27+
28+
def test_client_secret_is_obfuscated(self):
29+
with Feature({"organizations:tempest-access": True}):
30+
credentials = self.create_tempest_credentials(self.project)
31+
self.login_as(self.user)
32+
response = self.get_success_response(self.project.organization.slug, self.project.slug)
33+
assert response.data[0]["clientSecret"] == "*" * len(credentials.client_secret)
34+
35+
def test_unauthenticated_user_cant_access_endpoint(self):
36+
self.get_error_response(self.project.organization.slug, self.project.slug)

0 commit comments

Comments
 (0)