Skip to content

Commit e1d511f

Browse files
feat(preprod): Create artifact download endpoint + associated authentication code (#93865)
I implemented the authentication logic that will power the monolith <> launchpad specific endpoints. The code is exactly how seer currently has its auth HTTP calls implemented. I put it in a shared space so that once this lands, we could potentially have the seer team share this underlying logic too. That way we don't have two different implementations of the same thing As for "why this auth approach", I explored the different ways we currently have it implemented: 1. Relay: Public key + signature validation + IP allowlists 2. Cross-region RPC: Shared secret HMAC (RpcSignatureAuthentication) 3. Seer: Custom shared secret HMAC (SeerRpcSignatureAuthentication) 4. Codecov: JWT with shared signing secret 5. Taskbroker: gRPC interceptor with shared secrets I went with the #3 approach since our use case is pretty much identical to the Seer use case and the implementation seemed the most straightforward. Security folks though, please weigh in here! You know best I also created 1 of the 3 new endpoints that we need for the launchpad service. This one just allows our service to download the artifact file. I included it so that the full usage of this auth logic is apparent
1 parent f963b83 commit e1d511f

File tree

9 files changed

+604
-1
lines changed

9 files changed

+604
-1
lines changed

src/sentry/api/authentication.py

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

33
import hashlib
4+
import hmac
45
import logging
56
from collections.abc import Callable, Iterable
67
from typing import Any, ClassVar
@@ -25,7 +26,7 @@
2526
from sentry.auth.services.auth import AuthenticatedToken
2627
from sentry.auth.system import SystemToken, is_internal_ip
2728
from sentry.hybridcloud.models import ApiKeyReplica, ApiTokenReplica, OrgAuthTokenReplica
28-
from sentry.hybridcloud.rpc.service import compare_signature
29+
from sentry.hybridcloud.rpc.service import RpcAuthenticationSetupException, compare_signature
2930
from sentry.models.apiapplication import ApiApplication
3031
from sentry.models.apikey import ApiKey
3132
from sentry.models.apitoken import ApiToken
@@ -550,3 +551,98 @@ def authenticate_token(self, request: Request, token: str) -> tuple[Any, Any]:
550551
sentry_sdk.get_isolation_scope().set_tag("rpc_auth", True)
551552

552553
return (AnonymousUser(), token)
554+
555+
556+
def compare_service_signature(
557+
url: str,
558+
body: bytes,
559+
signature: str,
560+
shared_secret_setting: list[str],
561+
service_name: str,
562+
) -> bool:
563+
"""
564+
Generic function to compare request data + signature signed by one of the shared secrets.
565+
566+
Once a key has been able to validate the signature other keys will
567+
not be attempted. We should only have multiple keys during key rotations.
568+
569+
Args:
570+
url: The request URL path
571+
body: The request body
572+
signature: The signature to validate
573+
shared_secret_setting: List of shared secrets from settings
574+
service_name: Name of the service for logging (e.g., "Seer", "Launchpad")
575+
"""
576+
577+
if not shared_secret_setting:
578+
raise RpcAuthenticationSetupException(
579+
f"Cannot validate {service_name} RPC request signatures without shared secret"
580+
)
581+
582+
# Ensure no empty secrets
583+
if any(not secret.strip() for secret in shared_secret_setting):
584+
raise RpcAuthenticationSetupException(
585+
f"Cannot validate {service_name} RPC request signatures with empty shared secret"
586+
)
587+
588+
if not signature.startswith("rpc0:"):
589+
logger.error("%s RPC signature validation failed: invalid signature prefix", service_name)
590+
return False
591+
592+
try:
593+
# We aren't using the version bits currently.
594+
_, signature_data = signature.split(":", 2)
595+
596+
signature_input = body
597+
598+
for key in shared_secret_setting:
599+
computed = hmac.new(key.encode(), signature_input, hashlib.sha256).hexdigest()
600+
is_valid = constant_time_compare(computed.encode(), signature_data.encode())
601+
if is_valid:
602+
return True
603+
except Exception:
604+
logger.exception("%s RPC signature validation failed", service_name)
605+
return False
606+
607+
logger.error("%s RPC signature validation failed", service_name)
608+
609+
return False
610+
611+
612+
class ServiceRpcSignatureAuthentication(StandardAuthentication):
613+
"""
614+
Generic authentication for service RPC requests.
615+
Requests are sent with an HMAC signed by a shared private key.
616+
617+
Subclasses should define:
618+
- shared_secret_setting_name: str - name of the settings attribute (e.g., "SEER_RPC_SHARED_SECRET")
619+
- service_name: str - name of the service for logging (e.g., "Seer", "Launchpad")
620+
- sdk_tag_name: str - name for the SDK tag (e.g., "seer_rpc_auth", "launchpad_rpc_auth")
621+
"""
622+
623+
token_name = b"rpcsignature"
624+
shared_secret_setting_name: str
625+
service_name: str
626+
sdk_tag_name: str
627+
628+
def accepts_auth(self, auth: list[bytes]) -> bool:
629+
if not auth or len(auth) < 2:
630+
return False
631+
return auth[0].lower() == self.token_name
632+
633+
def authenticate_token(self, request: Request, token: str) -> tuple[Any, Any]:
634+
shared_secret_setting = getattr(settings, self.shared_secret_setting_name, None)
635+
636+
if shared_secret_setting is None:
637+
raise RpcAuthenticationSetupException(
638+
f"Cannot validate {self.service_name} RPC request signatures without shared secret"
639+
)
640+
641+
if not compare_service_signature(
642+
request.path_info, request.body, token, shared_secret_setting, self.service_name
643+
):
644+
raise AuthenticationFailed("Invalid signature")
645+
646+
sentry_sdk.get_isolation_scope().set_tag(self.sdk_tag_name, True)
647+
648+
return (AnonymousUser(), token)

src/sentry/api/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3276,6 +3276,7 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
32763276
EmailCaptureEndpoint.as_view(),
32773277
name="sentry-demo-mode-email-capture",
32783278
),
3279+
*preprod_urls.preprod_internal_urlpatterns,
32793280
]
32803281

32813282
PREVENT_URLS = [

src/sentry/conf/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,9 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
710710
# Shared secret used to sign cross-region RPC requests to the seer microservice.
711711
SEER_API_SHARED_SECRET: str = ""
712712

713+
# Shared secret used to sign cross-region RPC requests from the launchpad microservice.
714+
LAUNCHPAD_RPC_SHARED_SECRET: list[str] | None = None
715+
713716
# The protocol, host and port for control silo
714717
# Usecases include sending requests to the Integration Proxy Endpoint and RPC requests.
715718
SENTRY_CONTROL_ADDRESS: str | None = os.environ.get("SENTRY_CONTROL_ADDRESS", None)
@@ -3967,6 +3970,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
39673970
]
39683971
RPC_TIMEOUT = 15.0
39693972
SEER_RPC_SHARED_SECRET = ["seers-also-very-long-value-haha"]
3973+
LAUNCHPAD_RPC_SHARED_SECRET = ["launchpad-also-very-long-value-haha"]
39703974

39713975
# Key for signing integration proxy requests.
39723976
SENTRY_SUBNET_SECRET = "secret-subnet-signature"
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import posixpath
2+
3+
from django.http.response import FileResponse, HttpResponseBase
4+
from rest_framework.exceptions import PermissionDenied
5+
from rest_framework.request import Request
6+
from rest_framework.response import Response
7+
8+
from sentry.api.api_owners import ApiOwner
9+
from sentry.api.api_publish_status import ApiPublishStatus
10+
from sentry.api.base import region_silo_endpoint
11+
from sentry.api.bases.project import ProjectEndpoint
12+
from sentry.api.endpoints.project_release_file_details import ClosesDependentFiles
13+
from sentry.models.files.file import File
14+
from sentry.preprod.authentication import LaunchpadRpcSignatureAuthentication
15+
from sentry.preprod.models import PreprodArtifact
16+
17+
18+
@region_silo_endpoint
19+
class ProjectPreprodArtifactDownloadEndpoint(ProjectEndpoint):
20+
owner = ApiOwner.EMERGE_TOOLS
21+
publish_status = {
22+
"GET": ApiPublishStatus.PRIVATE,
23+
}
24+
authentication_classes = (LaunchpadRpcSignatureAuthentication,)
25+
permission_classes = ()
26+
27+
def _is_authorized(self, request: Request) -> bool:
28+
if request.auth and isinstance(
29+
request.successful_authenticator, LaunchpadRpcSignatureAuthentication
30+
):
31+
return True
32+
return False
33+
34+
def get(self, request: Request, project, artifact_id) -> HttpResponseBase:
35+
"""
36+
Download a preprod artifact file
37+
```````````````````````````````
38+
39+
Download the actual file contents of a preprod artifact.
40+
41+
:pparam string organization_id_or_slug: the id or slug of the organization the
42+
artifact belongs to.
43+
:pparam string project_id_or_slug: the id or slug of the project to retrieve the
44+
artifact from.
45+
:pparam string artifact_id: the ID of the preprod artifact to download.
46+
:auth: required
47+
"""
48+
if not self._is_authorized(request):
49+
raise PermissionDenied
50+
51+
try:
52+
preprod_artifact = PreprodArtifact.objects.get(
53+
project=project,
54+
id=artifact_id,
55+
)
56+
except PreprodArtifact.DoesNotExist:
57+
return Response({"error": f"Preprod artifact {artifact_id} not found"}, status=404)
58+
59+
if preprod_artifact.file_id is None:
60+
return Response({"error": "Preprod artifact file not available"}, status=404)
61+
62+
if preprod_artifact.state != PreprodArtifact.ArtifactState.PROCESSED:
63+
return Response(
64+
{
65+
"error": f"Preprod artifact is not ready for download (state: {preprod_artifact.get_state_display()})"
66+
},
67+
status=400,
68+
)
69+
70+
try:
71+
file_obj = File.objects.get(id=preprod_artifact.file_id)
72+
except File.DoesNotExist:
73+
return Response({"error": "Preprod artifact file not found"}, status=404)
74+
75+
try:
76+
fp = file_obj.getfile()
77+
except Exception:
78+
return Response({"error": "Failed to retrieve preprod artifact file"}, status=500)
79+
80+
# All preprod artifacts are zip files
81+
filename = f"preprod_artifact_{artifact_id}.zip"
82+
83+
response = FileResponse(
84+
ClosesDependentFiles(fp),
85+
content_type="application/octet-stream",
86+
)
87+
88+
response["Content-Length"] = file_obj.size
89+
response["Content-Disposition"] = f'attachment; filename="{posixpath.basename(filename)}"'
90+
91+
return response

src/sentry/preprod/api/endpoints/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.urls import re_path
22

33
from .organization_preprod_artifact_assemble import ProjectPreprodArtifactAssembleEndpoint
4+
from .project_preprod_artifact_download import ProjectPreprodArtifactDownloadEndpoint
45

56
preprod_urlpatterns = [
67
re_path(
@@ -9,3 +10,11 @@
910
name="sentry-api-0-assemble-preprod-artifact-files",
1011
),
1112
]
13+
14+
preprod_internal_urlpatterns = [
15+
re_path(
16+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/preprodartifacts/(?P<artifact_id>[^/]+)/$",
17+
ProjectPreprodArtifactDownloadEndpoint.as_view(),
18+
name="sentry-api-0-project-preprod-artifact-download",
19+
),
20+
]

src/sentry/preprod/authentication.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from sentry.api.authentication import AuthenticationSiloLimit, ServiceRpcSignatureAuthentication
2+
from sentry.silo.base import SiloMode
3+
4+
LAUNCHPAD_RPC_SHARED_SECRET_SETTING = "LAUNCHPAD_RPC_SHARED_SECRET"
5+
6+
7+
@AuthenticationSiloLimit(SiloMode.REGION)
8+
class LaunchpadRpcSignatureAuthentication(ServiceRpcSignatureAuthentication):
9+
"""
10+
Authentication for Launchpad RPC requests.
11+
Requests are sent with an HMAC signed by a shared private key.
12+
"""
13+
14+
shared_secret_setting_name = LAUNCHPAD_RPC_SHARED_SECRET_SETTING
15+
service_name = "Launchpad"
16+
sdk_tag_name = "launchpad_rpc_auth"

src/sentry/testutils/auth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import hashlib
2+
import hmac
3+
4+
from sentry.hybridcloud.rpc.service import RpcAuthenticationSetupException
5+
6+
7+
def generate_service_request_signature(
8+
url_path: str, body: bytes, shared_secret_setting: list[str] | None, service_name: str
9+
) -> str:
10+
"""
11+
Generate a signature for the request body with the first shared secret.
12+
If there are other shared secrets in the list they are only to be used
13+
for verification during key rotation.
14+
15+
Args:
16+
url_path: The request URL path (unused but kept for compatibility)
17+
body: The request body to sign
18+
shared_secret_setting: List of shared secrets from settings
19+
service_name: Name of the service for error messages
20+
21+
NOTE: This function is used only for testing and has been moved from
22+
production code since no actual services are using it in production yet.
23+
"""
24+
25+
if not shared_secret_setting:
26+
raise RpcAuthenticationSetupException(
27+
f"Cannot sign {service_name} RPC requests without shared secret"
28+
)
29+
30+
signature_input = body
31+
secret = shared_secret_setting[0]
32+
signature = hmac.new(secret.encode("utf-8"), signature_input, hashlib.sha256).hexdigest()
33+
return f"rpc0:{signature}"
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from django.test import override_settings
2+
3+
from sentry.preprod.models import PreprodArtifact
4+
from sentry.testutils.auth import generate_service_request_signature
5+
from sentry.testutils.cases import TestCase
6+
7+
8+
class ProjectPreprodArtifactDownloadEndpointTest(TestCase):
9+
def setUp(self):
10+
super().setUp()
11+
12+
# Create a test file
13+
self.file = self.create_file(
14+
name="test_artifact.apk",
15+
type="application/octet-stream",
16+
)
17+
18+
# Create a preprod artifact
19+
self.preprod_artifact = PreprodArtifact.objects.create(
20+
project=self.project,
21+
file_id=self.file.id,
22+
state=PreprodArtifact.ArtifactState.PROCESSED,
23+
artifact_type=PreprodArtifact.ArtifactType.APK,
24+
)
25+
26+
def _get_authenticated_request_headers(self, path, data=b""):
27+
"""Generate the RPC signature authentication headers for the request."""
28+
signature = generate_service_request_signature(path, data, ["test-secret-key"], "Launchpad")
29+
return {"HTTP_AUTHORIZATION": f"rpcsignature {signature}"}
30+
31+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
32+
def test_download_preprod_artifact_success(self):
33+
url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.preprod_artifact.id}/"
34+
35+
headers = self._get_authenticated_request_headers(url)
36+
37+
with self.feature("organizations:preprod-artifact-assemble"):
38+
response = self.client.get(url, **headers)
39+
40+
assert response.status_code == 200
41+
assert response["Content-Type"] == "application/octet-stream"
42+
assert "attachment" in response["Content-Disposition"]
43+
44+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
45+
def test_download_preprod_artifact_not_found(self):
46+
url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/999999/"
47+
48+
headers = self._get_authenticated_request_headers(url)
49+
50+
with self.feature("organizations:preprod-artifact-assemble"):
51+
response = self.client.get(url, **headers)
52+
53+
assert response.status_code == 404
54+
assert "not found" in response.json()["error"]
55+
56+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
57+
def test_download_preprod_artifact_not_processed(self):
58+
# Create an artifact that's not processed yet
59+
unprocessed_artifact = PreprodArtifact.objects.create(
60+
project=self.project,
61+
file_id=self.file.id,
62+
state=PreprodArtifact.ArtifactState.UPLOADING,
63+
)
64+
65+
url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{unprocessed_artifact.id}/"
66+
67+
headers = self._get_authenticated_request_headers(url)
68+
69+
with self.feature("organizations:preprod-artifact-assemble"):
70+
response = self.client.get(url, **headers)
71+
72+
assert response.status_code == 400
73+
assert "not ready for download" in response.json()["error"]
74+
75+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
76+
def test_download_preprod_artifact_no_file(self):
77+
# Create an artifact without a file
78+
no_file_artifact = PreprodArtifact.objects.create(
79+
project=self.project,
80+
file_id=None,
81+
state=PreprodArtifact.ArtifactState.PROCESSED,
82+
)
83+
84+
url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{no_file_artifact.id}/"
85+
86+
headers = self._get_authenticated_request_headers(url)
87+
88+
with self.feature("organizations:preprod-artifact-assemble"):
89+
response = self.client.get(url, **headers)
90+
91+
assert response.status_code == 404
92+
assert "file not available" in response.json()["error"]

0 commit comments

Comments
 (0)