Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
34d70d7
feat(scm): Add a git endpoint view that relays through scm-platform
vaind Oct 21, 2026
d303911
feat(scm): Route the git endpoint by repository id
vaind Oct 21, 2026
d292783
test(scm): Cover a git endpoint URL that ends at the repository
vaind Oct 21, 2026
75f0743
fix(scm): Keep git grants out of telemetry and answer token failures
vaind Oct 21, 2026
6b016ce
ref(scm): Simplify the git endpoint's refusals and tests
vaind Oct 21, 2026
7e374e4
ref(scm): Look up git tokens beside the provider factory
vaind Oct 22, 2026
b7a049e
feat(scm): Tag git endpoint failures with the grant's referrer
vaind Oct 22, 2026
bc9e1f5
feat(scm): Get git tokens from the provider-neutral access token RPC
vaind Oct 22, 2026
661073a
fix(scm): Keep refusing GitLab repositories on the git endpoint
vaind Oct 22, 2026
6ebcc26
fix(scm): Hold git grant secrets as scm.Secret
vaind Oct 26, 2026
9cf79f7
ref(scm): Say the git endpoint re-verifies a JWT, not a bare HMAC
vaind Oct 26, 2026
f2c8723
fix(scm): Log git requests refused for an internal provider address
vaind Oct 26, 2026
d24ccde
fix(scm): Answer an unreadable token RPC response with a 502
vaind Oct 26, 2026
50a2bd2
feat(scm): Report every git endpoint refusal as a Sentry issue
vaind Oct 27, 2026
c4bf1d9
:hammer_and_wrench: Sync API Urls to TypeScript
vaind Oct 27, 2026
15c880a
feat(scm): Relay git for GitLab repositories
vaind Oct 27, 2026
4c74d4f
test(scm): Keep a forged git grant's referrer out of metrics and issues
vaind Oct 27, 2026
777d490
fix(scm): Report git refusals without their exceptions
vaind Oct 27, 2026
73a670b
feat(scm): Name git repositories by provider and external id
vaind Oct 28, 2026
25df0e9
ref(scm): Give the git endpoint its own repository lookup
vaind Oct 28, 2026
feb0b1e
fix(scm): Fetch a GitLab repository's integration once per request
vaind Oct 28, 2026
f1a8276
feat(scm): Name git repositories by Sentry's repository id again
vaind Oct 28, 2026
8524097
test(scm): Check that the git endpoint continues the caller's trace
vaind Oct 29, 2026
24401b3
fix(scm): Hand the git endpoint's provider token over as a Secret
vaind Oct 29, 2026
1bbed21
feat(scm): Verify git grants with Ed25519 public keys
vaind Oct 29, 2026
4b3bbce
ref(scm): Let GitServer refuse a provider host on an internal address
vaind Oct 29, 2026
1fffcf9
fix(scm): Keep the git endpoint's 503s for failures Seer can retry
vaind Oct 30, 2026
298d34c
feat(scm): Trust the dev Seer's git grant key in local development
vaind Oct 30, 2026
17a53d7
ref(scm): Adopt scm-platform's grant key error and key helpers
vaind Oct 30, 2026
5ac8a96
fix(scm): Keep the grant key error's detail out of the git response
vaind Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@
from sentry.replays.endpoints.project_replay_summary import ProjectReplaySummaryEndpoint
from sentry.replays.endpoints.project_replay_video_details import ProjectReplayVideoDetailsEndpoint
from sentry.replays.endpoints.project_replay_viewed_by import ProjectReplayViewedByEndpoint
from sentry.scm.endpoints.scm_git import ScmGitEndpoint
from sentry.scm.endpoints.scm_rpc import ScmRpcServiceEndpoint
from sentry.seer.endpoints.admin_agentic_triage_trigger import SeerAdminAgenticTriageTriggerEndpoint
from sentry.seer.endpoints.admin_autofix_retry import SeerAdminAutofixRetryEndpoint
Expand Down Expand Up @@ -3783,6 +3784,15 @@ def create_group_urls(
ScmRpcServiceEndpoint.as_view(),
name="sentry-api-0-scm-rpc-service",
),
# No trailing slash, because git never sends one. scm-git/hosts/ is kept free for a later route
# that names repositories by their provider host.
re_path(
# [0-9] rather than \d, which also matches other scripts' digits. 19 digits hold any
# Repository.id, and keep int() from refusing a longer id with a 500.
r"^scm-git/repositories/(?P<repository_id>[0-9]{1,19})\.git/(?P<route>.+)$",
ScmGitEndpoint.as_view(),
name="sentry-api-0-scm-git",
),
re_path(
r"^seer-rpc/(?P<method_name>\w+)/$",
SeerRpcServiceEndpoint.as_view(),
Expand Down
17 changes: 16 additions & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,12 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
# First element is used to sign requests; request is accepted if signed with any element in the list.
SCM_RPC_SHARED_SECRET: list[str] | None = None

# Ed25519 public keys, as SubjectPublicKeyInfo PEM strings, that verify the grants Seer signs for
# the SCM git endpoint. Only Seer holds a private key, so these aren't secret. A grant is accepted
# if any listed key verifies it. To rotate, add the new public key here, switch Seer to the new
# private key, wait 15 minutes for its old grants to expire, then remove the old public key.
SCM_GIT_GRANT_PUBLIC_KEYS: list[str] = []

# Shared secret used to sign cross-region RPC requests from the launchpad microservice.
LAUNCHPAD_RPC_SHARED_SECRET: list[str] | None = None
if (val := os.environ.get("LAUNCHPAD_RPC_SHARED_SECRET")) is not None:
Expand Down Expand Up @@ -2636,7 +2642,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"http_headers": {
"request": {
"mode": "denylist",
"terms": ["forwarded", "-ip", "remote-", "via", "-user"],
# "grant" keeps the SCM git endpoint's X-Scm-Git-Grant out of spans and events.
# It's a credential, but none of the SDK's own terms match it.
"terms": ["forwarded", "-ip", "remote-", "via", "-user", "grant"],
}
},
"cookies": {"mode": "off"},
Expand Down Expand Up @@ -3680,6 +3688,13 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:

if SILO_DEVSERVER or IS_DEV:
LAUNCHPAD_RPC_SHARED_SECRET = ["launchpad-also-very-long-value-haha"]
# Dev only: verifies the grants a local Seer signs. Its private half is committed to Seer's
# .envrc, in a public repository, so anyone can sign grants this key accepts.
SCM_GIT_GRANT_PUBLIC_KEYS = [
"-----BEGIN PUBLIC KEY-----\n"
"MCowBQYDK2VwAyEAq8SFj6uFiEB74zliRdruAG69shgf1FNJbj/UCnK0jFM=\n"
"-----END PUBLIC KEY-----\n"
]

if SILO_DEVSERVER:
# Add connections for the region & control silo databases.
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:events-endpoint-transactions-discover-blocked", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable detecting SDK crashes during event processing
manager.add("organizations:sdk-crash-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Relay git fetches and pushes for coding jobs through Sentry's git endpoint, with the org's integration credentials
manager.add("organizations:scm-git-endpoint", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable Seer Agent panel for AI-powered data exploration
manager.add("organizations:seer-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable the Seer Automations UI and APIs
Expand Down
202 changes: 202 additions & 0 deletions src/sentry/scm/endpoints/scm_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import io
import logging
from collections.abc import Generator

import sentry_sdk
from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseBase, StreamingHttpResponse
from rest_framework.request import Request
from scm.errors import GitGrantKeyError, GitRemoteRefused, SCMCodedError
from scm.git.grant import verify_grant
from scm.git.relay import GitRelay
from scm.git.server import GitServer
from scm.git.types import GRANT_HEADER, Readable
from sentry_sdk import traces

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, internal_cell_silo_endpoint
from sentry.api.negotiation import PassthroughContentNegotiation
from sentry.exceptions import RestrictedIPAddress
from sentry.models.organization import Organization
from sentry.net.http import BlacklistAdapter
from sentry.scm.private.helpers import fetch_access_token, make_fetchers, record_count_metric
from sentry.utils.http import request_body_stream

logger = logging.getLogger(__name__)

FAILED_METRIC = "sentry.scm.git.request.failed"


def report_git_error(error: Exception, context: dict[str, str]) -> None:
# An SCMCodedError's detail never holds a URL or a credential. Any other exception is named
# rather than quoted, since its message could hold either.
if isinstance(error, SCMCodedError):
_report_failure(error.code, error.detail, context)
else:
_report_failure(type(error).__name__, type(error).__name__, context)


def make_git_server() -> GitServer:
fetchers = make_fetchers()
return GitServer(
public_keys=_grant_public_keys(),
fetch_repository=fetchers.fetch_repository,
fetch_provider=fetchers.fetch_provider,
get_token=fetch_access_token,
record_count=record_count_metric,
emit_error=report_git_error,
# A GitHub Enterprise or self-managed GitLab host is whatever the customer configured, so
# it may resolve to one of our own addresses.
relay=GitRelay(adapter=BlacklistAdapter, map_error=_map_relay_error),
)


def _map_relay_error(error: Exception) -> SCMCodedError | None:
# BlacklistAdapter refuses an internal address with Sentry's own exception, which scm-platform
# doesn't know. Mapped, GitServer counts, reports and answers it like its other refusals, and
# the report leaves out the address and the urllib3 frames that hold the token.
if isinstance(error, RestrictedIPAddress):
return GitRemoteRefused()
return None


@internal_cell_silo_endpoint
class ScmGitEndpoint(Endpoint):
"""
Serves git's smart-HTTP protocol to coding jobs, relaying each request to the repository's
provider with the organization's integration credentials. A grant Seer signs authorizes each
request, so the job never holds a provider credential.

Only the credential proxy Seer's jobs go through can reach this route, so every refusal is
reported as a Sentry issue: a refusal means Sentry or Seer is misconfigured, not that a
client misbehaved.
"""

publish_status = {"GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PRIVATE}
owner = ApiOwner.CODING_WORKFLOWS
authentication_classes = ()
permission_classes = ()
# git's bodies are packs and git asks for its own media types, so nothing is parsed or
# negotiated.
parser_classes = ()
content_negotiation_class = PassthroughContentNegotiation
enforce_rate_limit = False

@traces.trace
def get(self, request: Request, repository_id: str, route: str) -> HttpResponseBase:
return self._relay(request, int(repository_id), route)

@traces.trace
def post(self, request: Request, repository_id: str, route: str) -> HttpResponseBase:
return self._relay(request, int(repository_id), route)

def _relay(self, request: Request, repository_id: int, route: str) -> HttpResponseBase:
# The grant names the organization whose flag gates the endpoint, and it's verified before
# the organization is looked up, so a request without a valid grant never reaches the
# database. GitServer verifies the JWT again, which costs one Ed25519 signature check.
context = {"repository_id": str(repository_id)}
try:
grant = verify_grant(_grant_public_keys(), request.headers.get(GRANT_HEADER, ""))
except GitGrantKeyError as e:
# No grant could verify against the key list. That's Sentry's misconfiguration, not the
# grant's fault, so it's a 500 rather than a 403. Seer fails a checkout on a 500 at
# once, where it retries a 502 or 503, which no retry would fix. Only the report says
# which key is wrong: the client can't fix Sentry's settings.
return _refuse(
"grant_public_keys_invalid",
500,
f"SCM_GIT_GRANT_PUBLIC_KEYS is misconfigured: {e}",
context,
tags={},
client_detail="Sentry's git grant keys are misconfigured.",
)
except SCMCodedError as e:
return _refuse(e.code, 403, e.detail, context, tags={})

context |= {"organization_id": str(grant.organization_id), "referrer": grant.referrer}
tags = {"referrer": grant.referrer}
try:
organization = Organization.objects.get_from_cache(id=grant.organization_id)
except Organization.DoesNotExist:
return _refuse(
"organization_not_found",
404,
"The git grant's organization was not found.",
context,
tags,
)
if not features.has("organizations:scm-git-endpoint", organization):
return _refuse(
"git_endpoint_disabled",
404,
"The git endpoint is not enabled for this organization.",
context,
tags,
)

response = make_git_server().handle(
method=request.method or "",
repository_id=repository_id,
route=route,
query=request.META.get("QUERY_STRING", ""),
headers=dict(request.headers),
body=_request_body(request._request),
)
return StreamingHttpResponse(
response.content, status=response.status_code, headers=response.headers
)


def _grant_public_keys() -> list[str]:
return settings.SCM_GIT_GRANT_PUBLIC_KEYS


def _request_body(request: HttpRequest) -> Readable | None:
# Endpoint.dispatch has already read a body sent with a Content-Length into memory, and nginx
# sends every body that way in production, so a push is held whole up to nginx's size cap.
# Only a chunked body, as on the devserver, is still read as it streams.
body = request_body_stream(request)
if isinstance(body, bytes):
return io.BytesIO(body)
if isinstance(body, Generator):
# Only uwsgi hands over a chunked body as a generator, and Sentry serves with granian.
raise NotImplementedError("The git endpoint can't stream a chunked body under uwsgi.")
return body


def _refuse(
code: str,
status: int,
detail: str,
context: dict[str, str],
tags: dict[str, str],
*,
client_detail: str | None = None,
) -> HttpResponse:
# Counted and reported like the refusals GitServer answers itself. The client gets the detail
# too, unless client_detail replaces it.
record_count_metric(FAILED_METRIC, 1, {"code": code} | tags)
_report_failure(code, detail, context)
# Never a 401: on a 401 git asks for credentials, which a coding job doesn't have.
return _plain_text(detail if client_detail is None else client_detail, status)


def _report_failure(code: str, detail: str, context: dict[str, str]) -> None:
# git shows the client only the message, which leaves out the repository, organization and
# provider, so the log and the issue's tags carry them. The detail can vary within a code, so
# the fingerprint groups by code alone.
logger.error("scm.git.request_failed", extra={"code": code, "detail": detail, **context})
with sentry_sdk.new_scope() as scope:
scope.fingerprint = ["scm-git", code]
scope.set_tags(context)
# A message, never the exception: Sentry's SDK sends every frame's local variables
# unscrubbed, and the frames a refusal is raised from hold the provider token, and
# requests' hold its Authorization header.
sentry_sdk.capture_message(detail, level="error")


def _plain_text(message: str, status: int) -> HttpResponse:
# git shows a text/plain body to the user as "remote: <line>".
return HttpResponse(f"{message}\n", status=status, content_type="text/plain; charset=utf-8")
Comment thread
vaind marked this conversation as resolved.
Resolved
39 changes: 34 additions & 5 deletions src/sentry/scm/private/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

import sentry_sdk
from django.db.models import Q
from scm.errors import SCMCodedError, UnhandledException
from scm import Secret
from scm.errors import ResourceServiceUnavailable, SCMCodedError, UnhandledException
from scm.git.types import GitOperation
from scm.providers.cursor_origin.provider import CursorOriginProvider
from scm.providers.github.provider import GitHubProvider
from scm.providers.gitlab.provider import GitLabProvider
from scm.types import Provider, Repository, RepositoryId

from sentry.constants import ObjectStatus
from sentry.hybridcloud.rpc.service import RpcRemoteException, RpcResponseException
from sentry.integrations.errors import OrganizationIntegrationNotFound
from sentry.integrations.services.integration.model import RpcIntegration
from sentry.integrations.services.integration.service import integration_service
Expand Down Expand Up @@ -84,6 +87,35 @@ def fetch_service_provider(
return None


def fetch_access_token(repository: Repository, operation: GitOperation) -> Secret | None:
"""Return a token that can run git against the repository, minted or refreshed in control.

The token is the integration's: an installation token for GitHub and Cursor Origin, or the OAuth
token of the user who installed GitLab. It isn't scoped to the repository or the operation, so a
fetch gets the same token as a push. It's wrapped in a Secret right away, so the frames an
error report carries show it as [Filtered].

Raises ResourceServiceUnavailable if control can't be reached, which GitServer answers with a
503 that Seer retries. None, which GitServer answers with a 502, means control answered but
has no token for the repository.
"""
try:
access_token = integration_service.get_access_token(
integration_id=repository["integration_id"],
organization_id=repository["organization_id"],
)
except (RpcRemoteException, RpcResponseException):
# Control couldn't be reached, or answered with something other than an RPC response,
# such as a load balancer's error page. Both usually pass, so the request is worth
# retrying. Answered as an uncaught exception, it would be a 500 that git shows only as
# "HTTP 500".
sentry_sdk.capture_exception()
raise ResourceServiceUnavailable(
detail="Sentry could not reach its control silo for a provider token. The request can be retried."
) from None
return Secret(access_token.token) if access_token is not None else None


def fetch_repository(
organization_id: int,
repository_id: RepositoryId,
Expand Down Expand Up @@ -136,10 +168,7 @@ def fetch_repository(
base_host = domain_name.split("/", 1)[0]
web_base_url = f"https://{base_host}"
elif provider_name == "gitlab":
integration = integration_service.get_integration(
integration_id=repo.integration_id,
organization_id=organization_id,
)
integration = get_integration(organization_id, repo.integration_id)
if integration:
# The same instance URL the GitLab API client and source links are built from, so it
# keeps the scheme and any path prefix of a self-hosted instance. Installs from older
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type KnownSentryApiUrls =
| '/internal/project-config/'
| '/internal/projectkey-cell-mappings/'
| '/internal/rpc/$serviceName/$methodName/'
| '/internal/scm-git/repositories/$repositoryId.git/$route'
| '/internal/scm-rpc/'
| '/internal/seer-rpc/$methodName/'
| '/internal/seer/autofix/retry/'
Expand Down
48 changes: 48 additions & 0 deletions tests/sentry/conf/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import os
import subprocess
import sys

import pytest

from sentry.utils import json

DEV_GRANT_PUBLIC_KEY = (
"-----BEGIN PUBLIC KEY-----\n"
"MCowBQYDK2VwAyEAq8SFj6uFiEB74zliRdruAG69shgf1FNJbj/UCnK0jFM=\n"
"-----END PUBLIC KEY-----\n"
)


def scm_git_grant_public_keys(**environ: str) -> list[str]:
# The settings module reads the environment once, when it's imported, so each environment
# gets its own interpreter.
env = {
name: value
for name, value in os.environ.items()
if name not in ("SENTRY_ENVIRONMENT", "SENTRY_SILO_DEVSERVER")
}
result = subprocess.run(
[
sys.executable,
"-c",
"import json, sentry.conf.server as s; print(json.dumps(s.SCM_GIT_GRANT_PUBLIC_KEYS))",
],
env=env | environ,
capture_output=True,
text=True,
check=True,
)
return json.loads(result.stdout)


def test_production_trusts_no_git_grant_key() -> None:
# Self-hosted inherits this default, and the dev key's private half is public.
assert scm_git_grant_public_keys() == []


@pytest.mark.parametrize(
"environ",
[{"SENTRY_ENVIRONMENT": "development"}, {"SENTRY_SILO_DEVSERVER": "1"}],
)
def test_dev_trusts_the_key_local_seer_signs_with(environ: dict[str, str]) -> None:
assert scm_git_grant_public_keys(**environ) == [DEV_GRANT_PUBLIC_KEY]
Loading
Loading