Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a44b8a6
ref(helpers): Extract repository checks from initialize_provider
vaind Oct 20, 2026
f95a570
feat(git): Add git grants and a streaming git relay
vaind Oct 20, 2026
1ace97f
fix(git): Send tokens only over https and clarify the git module
vaind Oct 20, 2026
c18cabd
Merge remote-tracking branch 'origin/main' into ivan/git-grants-relay…
vaind Oct 20, 2026
35c92d8
fix(git): Report an unverifiable provider certificate as such
vaind Oct 20, 2026
a127814
fix(git): Let the caller choose the relay's connection adapter
vaind Oct 20, 2026
6d2d2bf
fix(git): Take the GitLab host only from web_base_url
vaind Oct 20, 2026
f21a127
fix(deps): Require urllib3 2.8.0 for the git relay's streaming
vaind Oct 20, 2026
747d120
fix(git): Answer a provider's 401 with a 502
vaind Oct 20, 2026
026b7c7
feat(git): Sign grants as HS256 JWTs
vaind Oct 20, 2026
bcf1f89
ref(git): Simplify GitServer's contract and the git tests
vaind Oct 20, 2026
a340f53
fix(git): Fail fast on a client reset and on empty secrets
vaind Oct 20, 2026
a0c232a
fix(git): Clone GitLab repositories from their path, not their name
vaind Oct 20, 2026
754a3fb
fix(git): Count a git request when its response stream ends
vaind Oct 20, 2026
c3957a2
test(git): Treat a client closing mid-body as a reset in the fake ups…
vaind Oct 20, 2026
0b62f41
fix(git): Take no proxy or CA settings from the environment
vaind Oct 20, 2026
54ed248
fix(gitlab): Build web links from the repository path
vaind Oct 20, 2026
a6802f0
ref(git): Share stream closing and the fake relay setup
vaind Oct 20, 2026
4711964
ref(git): Open a connection per relayed request
vaind Oct 20, 2026
88ab8e5
ref(git): Ask the provider for a repository's git remote
vaind Oct 20, 2026
2662dff
feat(cursor-origin): Give Origin repositories a git remote
vaind Oct 20, 2026
f04bef6
Merge remote-tracking branch 'origin/main' into ivan/git-grants-relay…
vaind Oct 20, 2026
20dd6a2
Merge branch 'ivan/git-remote-from-provider' into ivan/git-grants-rel…
vaind Oct 20, 2026
023be64
ref(git): Drop the resolve_repository extraction
vaind Oct 20, 2026
1b024ab
fix(git): Close unread provider responses and keep redirect bodies st…
vaind Oct 20, 2026
f593ce2
fix(git): Keep urllib3 errors and unencodable credentials from escapi…
vaind Oct 21, 2026
dd7a9b2
Merge branch 'ivan/git-remote-from-provider' into ivan/git-grants-rel…
vaind Oct 21, 2026
e8292aa
Merge branch 'ivan/git-remote-from-provider' into ivan/git-grants-rel…
vaind Oct 21, 2026
d1db822
docs(git): Drop Cursor Origin as the example of a provider without a …
vaind Oct 21, 2026
4c55d81
ref(git): Give each git refusal its own error code
vaind Oct 21, 2026
4f65294
fix(git): Count a provider's refusal as a failed request
vaind Oct 21, 2026
451e9f8
ref(git): Apply review feedback on the git endpoint
vaind Oct 21, 2026
78833cb
feat(git): Report git errors with what the request had resolved
vaind Oct 21, 2026
cd8d705
feat(git): Add a referrer to the git grant
vaind Oct 22, 2026
161ba13
feat(git): Refuse to sign a grant longer than 4096 characters
vaind Oct 22, 2026
7c8dd13
ref(git): Apply the second round of review feedback
vaind Oct 22, 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ The SCM (Source Code Management) platform is a vendor-agnostic abstraction layer

### Features

The platform exposes three subsystems:
The platform exposes four subsystems:

- **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases.
- **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith.
- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant naming the organization, operation and repositories with `scm.git.grant.sign_grant`, and `scm.git.server.GitServer` checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies.
- **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions.

### Why
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ authors = [
requires-python = ">=3.13"
dependencies = [
"msgspec>=0.19.0",
"pyjwt>=2.9.0",
"requests>=2.32.4",
# The git relay streams responses through urllib3 directly. Before 2.8.0, a server could make its chunked
# reader buffer without limit (GHSA-vxq7-64xx-v4gw).
"urllib3>=2.8.0",
]

[build-system]
Expand Down Expand Up @@ -50,6 +54,7 @@ select = [

[dependency-groups]
dev = [
"cryptography>=48.0.1",
"mypy>=1.20.0",
"prek>=0.3.6",
"pyjwt[crypto]>=2.9.0",
Expand Down
64 changes: 64 additions & 0 deletions src/scm/errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from typing import ClassVar, Literal

type ErrorCode = Literal[
"git_grant_expired",
"git_invalid_grant",
"git_operation_not_granted",
"git_provider_not_supported",
"git_remote_not_https",
"git_repository_not_granted",
"git_route_not_supported",
"git_token_rejected",
"git_token_unavailable",
"malformed_external_id",
"missing_repository_path",
"path_is_directory",
Expand All @@ -26,6 +35,7 @@
"resource_unprocessable_content",
"resource_server_error",
"resource_bad_gateway",
"resource_certificate_invalid",
"resource_service_unavailable",
"resource_gateway_timeout",
"unexpected_response_format",
Expand All @@ -38,6 +48,15 @@
]

ERROR_CODES: dict[ErrorCode, str] = {
"git_grant_expired": "The git grant has expired.",
"git_invalid_grant": "The git grant was missing, malformed or forged.",
"git_operation_not_granted": "The git grant does not allow this operation.",
"git_provider_not_supported": "The repository's provider has no git remote.",
"git_remote_not_https": "The repository's git remote is not an https URL.",
"git_repository_not_granted": "The git grant does not cover this repository.",
"git_route_not_supported": "The request is not a git fetch or push.",
"git_token_rejected": "The provider rejected Sentry's credentials for the repository.",
"git_token_unavailable": "No provider token could be obtained for the repository.",
"malformed_external_id": "The repository's external ID was malformed.",
"missing_repository_path": "No path was given for the repository.",
"path_is_directory": "The requested path is a directory, not a file.",
Expand All @@ -63,6 +82,7 @@
"resource_unprocessable_content": "Request could not be processed.",
"resource_server_error": "The service-provider encountered an internal error.",
"resource_bad_gateway": "The service-provider returned an invalid response from an upstream gateway.",
"resource_certificate_invalid": "The service-provider's TLS certificate could not be verified.",
"resource_service_unavailable": "The service-provider is temporarily unavailable. The request can be retried.",
"resource_gateway_timeout": "The service-provider did not respond in time. The request can be retried.",
"unexpected_response_format": "The response format was in an unexpected format.",
Expand Down Expand Up @@ -125,6 +145,46 @@ def from_code(cls, code: ErrorCode, detail: str | None = None) -> "SCMCodedError
return error_cls(detail=detail)


class GitSCMCodedError(SCMCodedError):
"""Base class for the errors of the git endpoint (``scm.git``), so a caller can catch them all at once."""


class GitGrantExpired(GitSCMCodedError):
code = "git_grant_expired"


class GitInvalidGrant(GitSCMCodedError):
code = "git_invalid_grant"


class GitOperationNotGranted(GitSCMCodedError):
code = "git_operation_not_granted"


class GitProviderNotSupported(GitSCMCodedError):
code = "git_provider_not_supported"


class GitRemoteNotHttps(GitSCMCodedError):
code = "git_remote_not_https"


class GitRepositoryNotGranted(GitSCMCodedError):
code = "git_repository_not_granted"


class GitRouteNotSupported(GitSCMCodedError):
code = "git_route_not_supported"


class GitTokenRejected(GitSCMCodedError):
code = "git_token_rejected"


class GitTokenUnavailable(GitSCMCodedError):
code = "git_token_unavailable"

Comment thread
vaind marked this conversation as resolved.

class MalformedExternalId(SCMCodedError):
code = "malformed_external_id"

Expand Down Expand Up @@ -233,6 +293,10 @@ class ResourceBadGateway(SCMCodedError):
code = "resource_bad_gateway"


class ResourceCertificateInvalid(SCMCodedError):
code = "resource_certificate_invalid"


class ResourceServiceUnavailable(SCMCodedError):
code = "resource_service_unavailable"

Expand Down
102 changes: 102 additions & 0 deletions src/scm/git/grant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Signing and verifying git grants.

A grant travels as an HS256 JWT, the format Sentry and Seer already sign viewer contexts with. The grant's fields
are claims, alongside ``exp``, ``iat`` and an ``aud`` of ``scm-git-grant``. The verifier requires that audience,
so no other token signed with the same secret, such as a viewer context, passes as a grant. A ``kid`` header
names the secret that signed the grant, as in Sentry's viewer contexts, so the verifier can hold several secrets
while they rotate.
"""

import hashlib
import time
from typing import Any

import jwt
import msgspec

from scm.errors import GitGrantExpired, GitInvalidGrant
from scm.git.types import GRANT_MAX_LENGTH, Grant

GRANT_AUDIENCE = "scm-git-grant"
_ALGORITHM = "HS256"
Comment on lines +20 to +21

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these values aren't configurable why bother making them constants? They could be inlined into the function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined the clock skew, which had one use, as PyJWT's leeway with its comment (7c8dd13). I kept the other three, because each is used in two places that must agree: GRANT_AUDIENCE and _ALGORITHM by both signing and verifying, and _JWT_CLAIMS as the claims PyJWT requires and the ones kept out of the Grant.

🤖 Addressed by Claude Code

# Claims the JWT layer owns. Every other claim must be a Grant field.
_JWT_CLAIMS = frozenset({"aud", "exp", "iat"})


def sign_grant(secret: str, grant: Grant, *, now: float | None = None) -> str:
# HMAC accepts an empty key, so an empty secret, say from an unset setting, would let anyone sign a grant.
if not secret:
raise ValueError("A git grant secret is empty.")
claims: dict[str, Any] = msgspec.to_builtins(grant)
claims["exp"] = claims.pop("expires_at")
claims["iat"] = int(time.time() if now is None else now)
claims["aud"] = GRANT_AUDIENCE
token = jwt.encode(claims, secret, algorithm=_ALGORITHM, headers={"kid": _key_id(secret)})
# Refused here, the signer's tests catch a grant that is too long, rather than the sandbox router in production.
if len(token) > GRANT_MAX_LENGTH:
raise ValueError(
f"The git grant is {len(token)} characters, over GRANT_MAX_LENGTH ({GRANT_MAX_LENGTH}). It travels in a "
"request header, and sandbox-router refuses a longer one."
)
return token


def verify_grant(secrets: list[str], token: str, *, now: float | None = None) -> Grant:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to require now and always dependency inject it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept it optional. Every production caller wants the wall clock, and making now required puts time.time() at each Sentry and Seer call site, where passing milliseconds or a stale value would quietly mint bad grants. The grant tests now pin now everywhere, since the new iat check would otherwise break them once the real clock passed their fixed NOW. Happy to make it required if you'd rather.

🤖 Addressed by Claude Code

"""Return the grant if one of ``secrets`` signed it and it has not expired.

Verification takes a list of secrets, as ``RpcServer`` does, so a new secret can be added before signers
switch to it and the old one removed after their grants expire.
"""
# As in sign_grant: a grant signed with an empty secret proves nothing.
if not all(secrets):
raise ValueError("A git grant secret is empty.")
now = time.time() if now is None else now
if not token:
raise GitInvalidGrant(detail="The git grant is missing.")
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.DecodeError:
raise GitInvalidGrant(detail="The git grant is malformed.") from None
secret = next((secret for secret in secrets if _key_id(secret) == kid), None)
if secret is None:
raise GitInvalidGrant(detail="The git grant was not signed with a known secret.")

try:
claims = jwt.decode(
token,
secret,
algorithms=[_ALGORITHM],
audience=GRANT_AUDIENCE,
# exp is checked below against an injectable clock, so tests can pin it. PyJWT checks iat, which it
# also requires to be a number.
options={"require": sorted(_JWT_CLAIMS), "verify_exp": False},
# How far in the future iat may be, in seconds. Seer signs grants on other hosts than the one verifying
# them, and a clock running a little ahead should not get its grants refused.
leeway=60,
)
except jwt.InvalidSignatureError:
raise GitInvalidGrant(detail="The git grant's signature does not match.") from None
except jwt.DecodeError:
raise GitInvalidGrant(detail="The git grant is malformed.") from None
except jwt.InvalidTokenError as e:
# The signature matched or was never reached, and PyJWT's reasons name only claims, not values.
raise GitInvalidGrant(detail=f"The git grant is invalid: {e}") from None

fields = {name: value for name, value in claims.items() if name not in _JWT_CLAIMS}
try:
grant = msgspec.convert({**fields, "expires_at": claims["exp"]}, type=Grant, strict=True)
except msgspec.ValidationError as e:
# The signature matched, so a holder of the secret wrote these claims; telling them why they are invalid
# gives nothing away.
raise GitInvalidGrant(detail=f"The git grant's payload is invalid: {e}") from None
Comment thread
vaind marked this conversation as resolved.

if grant.expires_at <= now:
raise GitGrantExpired(detail="The git grant has expired.")
if not grant.repositories:
raise GitInvalidGrant(detail="The git grant names no repositories.")
return grant


def _key_id(secret: str) -> str:
# The first 8 hex characters of the secret's SHA-256, the same key id Sentry's viewer contexts use.
return hashlib.sha256(secret.encode()).hexdigest()[:8]
Loading
Loading