Repository navigation
feat(scm): Add a git endpoint that relays through scm-platform #128444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+1,164
−8
Open
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 d303911
feat(scm): Route the git endpoint by repository id
vaind d292783
test(scm): Cover a git endpoint URL that ends at the repository
vaind 75f0743
fix(scm): Keep git grants out of telemetry and answer token failures
vaind 6b016ce
ref(scm): Simplify the git endpoint's refusals and tests
vaind 7e374e4
ref(scm): Look up git tokens beside the provider factory
vaind b7a049e
feat(scm): Tag git endpoint failures with the grant's referrer
vaind bc9e1f5
feat(scm): Get git tokens from the provider-neutral access token RPC
vaind 661073a
fix(scm): Keep refusing GitLab repositories on the git endpoint
vaind 6ebcc26
fix(scm): Hold git grant secrets as scm.Secret
vaind 9cf79f7
ref(scm): Say the git endpoint re-verifies a JWT, not a bare HMAC
vaind f2c8723
fix(scm): Log git requests refused for an internal provider address
vaind d24ccde
fix(scm): Answer an unreadable token RPC response with a 502
vaind 50a2bd2
feat(scm): Report every git endpoint refusal as a Sentry issue
vaind c4bf1d9
:hammer_and_wrench: Sync API Urls to TypeScript
vaind 15c880a
feat(scm): Relay git for GitLab repositories
vaind 4c74d4f
test(scm): Keep a forged git grant's referrer out of metrics and issues
vaind 777d490
fix(scm): Report git refusals without their exceptions
vaind 73a670b
feat(scm): Name git repositories by provider and external id
vaind 25df0e9
ref(scm): Give the git endpoint its own repository lookup
vaind feb0b1e
fix(scm): Fetch a GitLab repository's integration once per request
vaind f1a8276
feat(scm): Name git repositories by Sentry's repository id again
vaind 8524097
test(scm): Check that the git endpoint continues the caller's trace
vaind 24401b3
fix(scm): Hand the git endpoint's provider token over as a Secret
vaind 1bbed21
feat(scm): Verify git grants with Ed25519 public keys
vaind 4b3bbce
ref(scm): Let GitServer refuse a provider host on an internal address
vaind 1fffcf9
fix(scm): Keep the git endpoint's 503s for failures Seer can retry
vaind 298d34c
feat(scm): Trust the dev Seer's git grant key in local development
vaind 17a53d7
ref(scm): Adopt scm-platform's grant key error and key helpers
vaind 5ac8a96
fix(scm): Keep the grant key error's detail out of the git response
vaind File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.