Skip to content

feat(scm): Add a git endpoint that relays through scm-platform - #128444

Open
vaind wants to merge 30 commits into
masterfrom
ivan/scm-git-endpoint-view
Open

vaind wants to merge 30 commits into
masterfrom
ivan/scm-git-endpoint-view

Conversation

@vaind

@vaind vaind commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Seer's coding jobs need to clone, fetch and push customer repositories without ever holding a GitHub or GitLab token. This adds an internal endpoint that speaks git's HTTP protocol and relays each request to the provider, adding the org's integration token on the way:

/api/0/internal/scm-git/repositories/{repository_id}.git/{info/refs | git-upload-pack | git-receive-pack}

Repositories are named by Sentry's Repository.id, as in Seer's grants. Provider external ids aren't unique: two GitHub Enterprise servers connected to one org can reuse the same id.

Each request carries a grant in X-Scm-Git-Grant: a short-lived token, signed by Seer, for one org, one operation (fetch or push) and a list of repositories. The view verifies the grant, checks the organizations:scm-git-endpoint flag, and hands the request to scm-platform's scm.git (sentry-scm 3.0.0). It refuses anything the grant doesn't cover before looking up the repository or minting a token. Refusals are plain text, which git shows as remote: <reason>, and each one is reported as a Sentry issue, grouped by reason: only Seer's credential proxy can reach this route, so a refusal means something is misconfigured.

  • Tokens: control mints or refreshes them through integration_service.get_access_token (feat(integrations): Add a provider access token RPC for cells #128490, feat(integrations): Add GitLab to the access token RPC #128762), for GitHub, GitHub Enterprise, GitLab and Cursor Origin. They're wrapped in scm.Secret, so error reports show [Filtered].
  • Provider hosts: requests go through BlacklistAdapter. Hosts on internal addresses and plain-http GitLab are refused.
  • Telemetry: the grant header is on the SDK's denylist. Each request continues Seer's sentry-trace, which isn't passed on to the provider.
  • Pushes are held in worker memory up to nginx's body limit, as objectstore and debug-file uploads already are.

Status codes follow how Seer's checkout (getsentry/seer#9229) reacts. It retries a 413, 429, 502, 503 or 504, and fails at once on anything else. So bad grants get a 403, and bugs or misconfiguration get a 500. An unreachable control silo gets a 503.

Grant keys

Grants are signed with Ed25519. Seer holds the only private key (SCM_GIT_GRANT_SIGNING_KEY). Sentry lists the public keys in SCM_GIT_GRANT_PUBLIC_KEYS as PEM strings. They aren't secret, so nothing that reads Sentry's settings can mint a grant.

openssl genpkey -algorithm ed25519 -out grant.pem   # private key, Seer only
openssl pkey -in grant.pem -pubout                  # public key, for Sentry's list
  • Rotation: add the new public key to Sentry, switch Seer's private key, wait 15 minutes for old grants to expire, then remove the old public key. Each grant names its key, so there's no key-id setting.
  • Bad config: an empty or invalid list gets a 500. The issue says which entry is wrong, without quoting it.
  • Local dev: Sentry trusts the dev key whose private half is committed in Seer's .envrc. Production and self-hosted keep the empty default, and a test checks that.

Before an org gets the flag

  • Both keys are configured in Seer and Sentry.
  • The cells' outbound IPs are on the published allow list. The cell calls the provider itself, because control's integration proxy buffers bodies and rewrites Authorization.
  • Capacity: each clone or push holds a web worker for the whole transfer. Check which pool serves /api/0/internal/scm-git/, that nginx and load-balancer timeouts allow transfers of several minutes, and that many large clones can't starve that pool's other traffic.

Refs CW-2222

@linear-code

linear-code Bot commented Oct 21, 2026

Copy link
Copy Markdown

CW-2222

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Oct 21, 2026
@vaind
vaind changed the base branch from master to ivan/integration-access-token-rpc October 22, 2026 07:13
@vaind
vaind added this pull request to stack #128492 October 22, 2026 07:39
Base automatically changed from ivan/integration-access-token-rpc to master October 22, 2026 12:45
@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Oct 26, 2026
@sentry

sentry Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Sentry Snapshot Testing

Name Added Removed Changed Renamed Unchanged Skipped Status
sentry-frontend
sentry-frontend
0 0 0 0 780 0 ✅ Unchanged

⚙️ sentry-frontend Snapshot Settings

@vaind
vaind removed this pull request from stack #128492 October 27, 2026 18:57
@vaind
vaind changed the base branch from master to ivan/api-url-codegen-unescape October 27, 2026 19:04
@vaind
vaind added this pull request to stack #128946 October 27, 2026 20:50
Base automatically changed from ivan/api-url-codegen-unescape to master October 27, 2026 21:32
vaind added a commit that referenced this pull request Oct 27, 2026
…8751)

CI turns every Django API route into a TypeScript string in
`knownSentryApiUrls.generated.ts`, so frontend calls to `getApiUrl()`
are checked against real routes. The generator stripped regex groups and
character classes but copied escapes through, so a route that matches a
literal dot, such as `\.git` in the git endpoint added in #128444, came
out as `'/internal/scm-git/repositories/$repositoryId\.git/$route'`.
oxlint rejects that string as a useless escape, which fails lint on any
PR that adds such a route.

Escaped punctuation is now written as the character itself
(`$repositoryId.git`). No route on master has an escape, so the
generated file doesn't change here.

Refs
[CW-2222](https://linear.app/getsentry/issue/CW-2222/git-endpoint-view-in-sentry)
@vaind
vaind force-pushed the ivan/scm-git-endpoint-view branch from cfdef2d to f214b2b Compare October 27, 2026 22:08
@vaind
vaind marked this pull request as ready for review October 27, 2026 22:54
@vaind
vaind requested review from a team as code owners October 27, 2026 22:54
@vaind
vaind marked this pull request as draft October 27, 2026 22:55
@vaind
vaind marked this pull request as ready for review October 27, 2026 22:58
@vaind
vaind marked this pull request as draft October 27, 2026 22:59
@vaind
vaind marked this pull request as ready for review October 27, 2026 23:05
vaind added a commit to getsentry/scm-platform that referenced this pull request Oct 28, 2026
When Sentry's git endpoint
(getsentry/sentry#128444) refuses a request
after it has fetched a provider token, for example because the provider
is unreachable or rejects the token, the error report carries the token.
Sentry's SDK sends the local variables of every frame in the traceback,
Sentry's SDK configuration turns off the scrubber that would otherwise
replace a variable named `token`, and `GitServer.handle` and
`GitRelay.forward` both hold the token as a plain string. #162 fixed
this for grant secrets and the grant, but left the token a `str`.

## Approach

- `GetToken` returns `scm.Secret | None`, and `GitServer.handle` passes
the `Secret` on to `GitRelay.forward`, so both frames show `[Filtered]`.
The relay calls `reveal()` only inline, for the ASCII check and
`HTTPBasicAuth`.
- `GitServer.handle` refuses a token that isn't a `Secret` with a
`TypeError`, deleting the local first so that error's report doesn't
carry it either. A plain string from untyped code would otherwise fail
every request in the relay with an `AttributeError` whose frames hold
it.
- `bin/git-server` wraps its tokens.

`get_token` now has to wrap the token in `Secret`. Nothing calls it yet:
Sentry's endpoint is still unmerged and will wrap it when it bumps to
the release with this change.

Not covered: an error the relay's adapter raises itself, such as
Sentry's `BlacklistAdapter` refusing an internal address, passes through
urllib3's frames, which hold the `Authorization` header. The relay can't
mask those, so `GitRelay`'s docstring now says to report such an error
without its frames' local variables; Sentry's endpoint handles that
separately.
@vaind
vaind requested a review from wedamija October 28, 2026 15:35
vaind added 29 commits November 2, 2026 08:09
The settled layout is /api/0/internal/scm-git/repositories/{repository_id}.git/{route}. The
path carries the Sentry Repository.id that the grant must list, so GitServer's own grant check
refuses any other repository before the database, the token or the provider. The view no longer
matches the path against repository names, which collide between GitHub, GitHub Enterprise and
Cursor Origin. scm-git/hosts/ stays free for a later route that names repositories by host.

Refs CW-2222
Review fixes for the git endpoint:
- Add "grant" to the SDK's request header denylist. None of its terms matched X-Scm-Git-Grant,
  so the grant reached spans and error events.
- Answer a GitHub token that can't be minted (e.g. a suspended app) with GitServer's text/plain
  502 rather than a JSON 500, which git shows only as "HTTP 500".
- Limit the route's repository id to 19 ASCII digits. \d also matches other scripts' digits,
  and int() refuses an id of 4,301 digits with a 500.
- Test org scoping with a repository that has a working integration, and test unmatched URLs at
  the resolver, where they reach the API catch-all.

Refs CW-2222
SCM errors always carry a detail, so the refusal helper no longer falls back to the error code.
The tests share a helper for the info/refs request that opens every fetch, and the inactive
repository test now checks the refusal like its siblings.

Refs CW-2222
Which token a repository's provider takes is per-provider knowledge, like which adapter to
build, so the lookup moves from the git endpoint into sentry.scm.private.helpers next to
fetch_service_provider. The GitLab and Cursor Origin branches then land in the same place as
their adapters.

Refs CW-2222
scm-platform's GitServer now takes an emit_error callback and tags its counts with the referrer
Seer puts in each grant. The view logs each failure with what the request had resolved, because
git shows the client only the message. Its own refusals (no organization, flag off) carry the same
referrer tag and log line, so they read like the ones GitServer answers.

Refs CW-2222
integration_service.get_access_token mints or refreshes the token in control and sends only the
token to the cell, for GitHub, GitHub Enterprise, GitLab and Cursor Origin. The git endpoint drops
its own switch on provider, GitLab and Cursor Origin repositories now relay instead of being
refused, and the cell no longer receives the full integration metadata just to read a token. The
git server also builds its repository and provider lookups with make_fetchers, so the two share
one integration fetch per request.

Refs CW-2222
The token RPC now leaves GitLab out until GitLab's client refreshes tokens before they expire, and
answers None for it. The git server turns None into a 502, which tells git there was an outage when
Sentry simply can't relay GitLab yet, so the endpoint refuses GitLab repositories as not supported
before asking for a token, as it did before the switch to the RPC.

Refs CW-2222
sentry-scm 2.0.0 takes signing secrets as scm.Secret, which shows as [Filtered] in error reports.
Held as plain strings in a list, a failure while verifying a grant would have sent every
SCM_GIT_GRANT_SECRETS entry to Sentry in the frame's locals, and GitServer now refuses to be built
with them. The setting stays a list of strings and each entry is wrapped where it's read.

Refs CW-2222
The grant is an HS256 JWT. The comment named only the HMAC it costs, which read as if grants were bare HMAC signatures.

Refs CW-2222
A GitHub Enterprise or self-managed GitLab host that resolves to an internal address was answered
with a 503 but never logged, and GitServer counts it as an unhandled_exception. Nothing named the
organization or repository, so a customer's misconfigured host looked like a bug in the endpoint.
The view now logs it as restricted_ip_address with the blocked address and the request's context.

Refs CW-2222
When control answered the token RPC with something other than an RPC response, such as a load
balancer's error page, RpcResponseException escaped fetch_access_token and git showed a bare
"HTTP 500". It's now handled like an unreachable control silo: reported to Sentry and answered as
the 502 that says Sentry couldn't get a token.

Refs CW-2222
Only the credential proxy Seer's coding jobs go through can reach the git
endpoint, so a refused request is never a stranger knocking: a missing or
unsigned grant, a disabled flag or a grant that doesn't match the request
means Sentry or Seer is misconfigured. A warning log doesn't surface that,
and Sentry's logging integration never turns a log into an issue, so the
endpoint now captures each refusal explicitly and logs it at error level.

The view's own refusals, the ones GitServer hands back through emit_error,
and a provider host on an internal address share one helper, so all three
report the same way. Issues group by refusal code, since the detail varies
within a code, and carry the repository, organization, referrer and
whatever else the request had resolved as tags, which the message leaves
out.
The endpoint refused GitLab only because the token RPC left it out.
#128762 added GitLab to the RPC, and sentry-scm already
relays to a GitLab remote with the oauth2 user, so the refusal now only
blocks repositories that work.
Anyone can write a referrer into a grant they sign themselves, and the
referrer tags Datadog metrics and Sentry issues. Only a missing grant was
checked for leaving it out; a forged one that carries a referrer was not.
Sentry's SDK config sets data_collection, which turns off the SDK's
event scrubber while every frame's local variables are still sent. A
refusal raised after the token lookup has GitServer.handle and
GitRelay.forward on its stack, both holding the provider token, and a
host on an internal address raises from inside requests, whose frames
hold the Authorization header. capture_exception sent all of that.

Each refusal is now a message grouped and tagged as before, which
carries no stack. The internal-address report also leaves out the
address, and an exception that isn't an SCMCodedError is reported by
its name, since its message could hold a URL or a credential.
Seer signs git grants from its repository definitions, which hold only
the provider and external id, and scm-platform covers a request only
when the grant lists its repository in the same form. With Sentry's
integer id in the URL, no grant Seer can sign would ever match.

The repository is looked up by external id alone. The RPC lookup also
matches a repository's name, which is chosen on the provider, so a
repository named after a granted external id could stand in for the
granted one. The RPC endpoint keeps matching names.
A git grant only ever names a repository by provider and external id,
so the git endpoint has no use for fetch_repository's lookups by
Sentry's id or by name. A flag on the shared lookup left those paths a
default away. The git endpoint now looks repositories up with a
function that has neither, and fetch_repository is back to what the RPC
endpoint had. Both still build the repository the same way.
Looking up a GitLab repository fetched its integration from control
directly, bypassing the cache it shares with the provider lookup, so
every GitLab request made the same RPC twice.
A provider and external id don't identify a repository across hosts.
GitHub Enterprise external ids are each server's own repository ids, so
two servers connected to one organization number their repositories
from the same range. Repository.id stays unique however Sentry comes to
tell such repositories apart, so the URL and grants name it again, and
Seer signs grants with the id Sentry already sends it for each
repository.

This takes back the provider and external id route and the lookup only
it needed, and keeps fetching a GitLab repository's integration once
per request.
Seer and the credential proxy send sentry-trace with every git request,
without baggage, so a fetch or push should join the trace of the Seer
step that ran it. The SDK's WSGI middleware does this already, and
nothing on the endpoint's path starts a trace of its own. Sentry and
getsentry don't set strict_trace_continuation, which would refuse a
trace that comes without baggage. Tests run without an SDK client, so
nothing showed it before.

The test serves a fetch inside the middleware with a client configured
like Sentry's. The provider request's span is in the caller's trace,
under the caller's span, and is sampled as the caller's flag says or by
Sentry's sampler without one. sentry-trace isn't passed on to the
provider.
scm-platform now requires get_token to return an scm.Secret, so the
token shows as [Filtered] in the frames of an error report instead of in
plain text. GitServer refuses a plain string with a TypeError, which
failed every relayed request against the unreleased 3.0.0 code.
Seer signs git grants and Sentry only verifies them. With a shared
HS256 secret, anything that can read Sentry's settings could mint a
grant for any connected repository. scm-platform 3.0.0 signs grants
with Ed25519 instead: Seer holds the only private key, and Sentry lists
the public keys, which aren't secret, as it already does for objectstore
tokens and Relay.

SCM_GIT_GRANT_SECRETS becomes SCM_GIT_GRANT_PUBLIC_KEYS. An empty or
unusable key list is now answered with a 503 that names the setting,
since scm-platform raises on it rather than refusing the grant, and it
is Sentry's misconfiguration rather than a bad grant.
scm-platform's GitRelay now takes map_error, so BlacklistAdapter's
RestrictedIPAddress becomes its git_remote_refused error. GitServer then
counts, reports and answers it like any other refusal, with the
operation, provider and host in the report, and the endpoint no longer
needs its own handler. The answer moves from a 503 to a 502: retrying a
refused host never helps.
Seer retries a 503 during checkout, so a 503 has to mean a retry can
work. A misconfigured SCM_GIT_GRANT_PUBLIC_KEYS never fixes itself, so
it's now a 500, like any other Sentry bug or misconfiguration. Control
being unreachable while Sentry fetches a provider token usually passes,
so it's now a 503 rather than the 502 GitServer gives a missing token.
A local Seer signs git grants with a dev-only private key committed to
its .envrc. Listing the matching public key in Sentry's dev settings
lets the two talk without each developer generating a key pair. Since
that private key is public, only the dev guard sets it, and a test
checks that the production default stays empty.
scm-platform now raises GitGrantKeyError for every unusable grant key,
so the endpoint catches that from verify_grant instead of checking the
key list separately for ValueError and TypeError. Tests make keys with
generate_private_key and public_key_of rather than copying the
cryptography calls.

A provider host on an internal address now answers 500, as
scm-platform maps git_remote_refused: Seer retries a 502, and no retry
reaches a host Sentry refuses to connect to.
The git endpoint answered a misconfigured key list with scm-platform's
exception message. That message names a bad key by its place in the
list without quoting it, but the client can't fix Sentry's settings,
and CodeQL flags exception text reaching a response. The client now gets
a fixed message, and the log and the Sentry issue keep the detail.
@vaind
vaind force-pushed the ivan/scm-git-endpoint-view branch from 88b7086 to 5ac8a96 Compare November 2, 2026 07:09

This branch was successfully deployed

1 active (outdated) deployment
Preview — b7cd2175 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants