Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c2603dc
feat(scm): Add a git endpoint view that relays through scm-platform
vaind Oct 21, 2026
387e31d
feat(scm): Route the git endpoint by repository id
vaind Oct 21, 2026
a5b8cae
test(scm): Cover a git endpoint URL that ends at the repository
vaind Oct 21, 2026
42ea608
fix(scm): Keep git grants out of telemetry and answer token failures
vaind Oct 21, 2026
50f7a89
ref(scm): Simplify the git endpoint's refusals and tests
vaind Oct 21, 2026
2104f8b
ref(scm): Look up git tokens beside the provider factory
vaind Oct 22, 2026
34855c5
feat(scm): Tag git endpoint failures with the grant's referrer
vaind Oct 22, 2026
e4014f8
feat(scm): Get git tokens from the provider-neutral access token RPC
vaind Oct 22, 2026
4b87d21
fix(scm): Keep refusing GitLab repositories on the git endpoint
vaind Oct 22, 2026
f5b72ea
fix(scm): Hold git grant secrets as scm.Secret
vaind Oct 26, 2026
b3d2ace
ref(scm): Say the git endpoint re-verifies a JWT, not a bare HMAC
vaind Oct 26, 2026
5c2d2b1
fix(scm): Log git requests refused for an internal provider address
vaind Oct 26, 2026
38215c4
fix(scm): Answer an unreadable token RPC response with a 502
vaind Oct 26, 2026
3888e77
feat(scm): Report every git endpoint refusal as a Sentry issue
vaind Oct 27, 2026
64f6ce5
:hammer_and_wrench: Sync API Urls to TypeScript
vaind Oct 27, 2026
f214b2b
feat(scm): Relay git for GitLab repositories
vaind Oct 27, 2026
3a75c94
test(scm): Keep a forged git grant's referrer out of metrics and issues
vaind Oct 27, 2026
90377d8
fix(scm): Report git refusals without their exceptions
vaind Oct 27, 2026
56fb36e
feat(scm): Name git repositories by provider and external id
vaind Oct 28, 2026
b9787e5
ref(scm): Give the git endpoint its own repository lookup
vaind Oct 28, 2026
893d00e
fix(scm): Fetch a GitLab repository's integration once per request
vaind Oct 28, 2026
1f4633f
feat(scm): Name git repositories by Sentry's repository id again
vaind Oct 28, 2026
013c3f2
test(scm): Check that the git endpoint continues the caller's trace
vaind Oct 29, 2026
60c8bb1
fix(scm): Hand the git endpoint's provider token over as a Secret
vaind Oct 29, 2026
5028559
feat(scm): Verify git grants with Ed25519 public keys
vaind Oct 29, 2026
e12c672
ref(scm): Let GitServer refuse a provider host on an internal address
vaind Oct 29, 2026
d3e77aa
fix(scm): Keep the git endpoint's 503s for failures Seer can retry
vaind Oct 30, 2026
49d0b94
feat(scm): Trust the dev Seer's git grant key in local development
vaind Oct 30, 2026
2681e6e
ref(scm): Adopt scm-platform's grant key error and key helpers
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 @@ -572,6 +572,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 @@ -3704,6 +3705,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 @@ -801,6 +801,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 @@ -2572,7 +2578,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 @@ -3615,6 +3623,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 @@ -270,6 +270,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
194 changes: 194 additions & 0 deletions src/sentry/scm/endpoints/scm_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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. The message names a
# key by its place in the list, never its contents.
return _refuse(
"grant_public_keys_invalid",
500,
f"SCM_GIT_GRANT_PUBLIC_KEYS is misconfigured: {e}",
context,
tags={},
)
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]
) -> HttpResponse:
# Counted and reported like the refusals GitServer answers itself.
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, 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")
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