Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6e14e7f
feat(git): Hold a sandbox's git access as rules in one grant
vaind Nov 9, 2026
9632fff
feat(git): Let push rules match branch patterns and allow only new br…
vaind Nov 9, 2026
33cb9a9
docs(git): Describe only GrantV2 in the README
vaind Nov 9, 2026
bdabfeb
fix(git): Name the malformed body error after the body, and sign v1 q…
vaind Nov 9, 2026
4cd5d5f
ref(git): Rename Grant to GrantV1 and keep Grant as an alias
vaind Nov 9, 2026
bad38fb
docs(git): Note that the Grant alias goes with GrantV1
vaind Nov 9, 2026
074157e
ref(git): Refuse pushes outside their rules as git_operation_not_granted
vaind Nov 9, 2026
94b83fc
ref(git): Deprecate GRANT_VERSION
vaind Nov 9, 2026
292060f
ref(git): Name the rule types after GrantV2 and drop the public Rule …
vaind Nov 9, 2026
356d3cb
fix(git): Fail closed on every push the rules can't vouch for
vaind Nov 9, 2026
0e2f08a
fix(git): Refuse any push line git wouldn't send
vaind Nov 9, 2026
de3eb25
test(git): Add failing tests for pushes that slip past the push check
vaind Nov 9, 2026
c3ae614
ref(git): Check version 1 and version 2 grants in separate functions
vaind Nov 9, 2026
a266f07
Merge branch 'refs/heads/ivan/push-adversarial-review-a46c63' into iv…
vaind Nov 9, 2026
a3fbc3c
fix(git): Refuse push refs, capabilities and headers git wouldn't send
vaind Nov 9, 2026
e82d8cc
fix(git): Take only what git sends in a push, in every request and line
vaind Nov 9, 2026
810c280
ref(git): Simplify the push parser loop and route checks
vaind Nov 9, 2026
f7db0e6
feat(git): Let fetch and push rules name tags
vaind Nov 10, 2026
3a2ed59
test(git): Show a digit class in a ref pattern covering non-ASCII digits
vaind Nov 10, 2026
8c3b33f
fix(git): Run rule patterns with ASCII character classes
vaind Nov 10, 2026
74bb772
test(git): Check that the provider keeps refs apart from case variants
vaind Nov 10, 2026
9c0915a
docs(git): Describe the push check's tests by what they guard
vaind Nov 10, 2026
6e104a4
feat(git)!: Accept only version 2 git grants
vaind Nov 11, 2026
efda0a2
ref(git)!: Name the grant and its rules plainly, and authorize in Git…
vaind Nov 11, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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 an Ed25519 private key only Seer holds. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it.
- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `Grant` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches and tags it may touch, or `any_object` for a fetch that may read any object. Both kinds of rule name branches by `branches` or full-match `branch_patterns`, and tags by `tags` or `tag_patterns`, and a push rule's `create_only` limits it to new refs. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes one or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it.
- **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
202 changes: 177 additions & 25 deletions bin/git-server

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/scm/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
type ErrorCode = Literal[
"git_grant_expired",
"git_invalid_grant",
"git_malformed_request_body",
"git_operation_not_granted",
"git_provider_not_supported",
"git_remote_not_https",
Expand Down Expand Up @@ -51,6 +52,7 @@
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_malformed_request_body": "The request body is not one the git endpoint can read.",
"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.",
Expand Down Expand Up @@ -170,6 +172,10 @@ class GitInvalidGrant(GitSCMCodedError):
code = "git_invalid_grant"


class GitMalformedRequestBody(GitSCMCodedError):
code = "git_malformed_request_body"


class GitOperationNotGranted(GitSCMCodedError):
code = "git_operation_not_granted"

Expand Down
14 changes: 9 additions & 5 deletions src/scm/git/grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
# The only algorithm the verifier accepts, with parsed Ed25519 keys. A token that names HS256 and uses a public key,
# which is no secret, as its HMAC key is refused, as is one that names ``none``.
_ALGORITHM = "EdDSA"
# Claims the JWT layer owns. Every other claim must be a Grant field.
# Claims the JWT layer owns. Every other claim must be a field of the grant.
_JWT_CLAIMS = frozenset({"aud", "exp", "iat"})


Expand Down Expand Up @@ -114,6 +114,8 @@ def require_private_key(private_key: Secret) -> Secret:
def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> Grant:
"""Return the grant if the private key of one of ``public_keys`` signed it and it has not expired.

Only version 2 grants verify. A version 1 grant, which lets a push update any ref, is refused.

Verification takes a list of Ed25519 public keys in PEM, so a new key can be added before the signer switches to
it and the old one removed after its grants expire. Raises GitGrantKeyError, rather than refusing the grant, if
the list is empty or a key is not one, since no grant could verify against it. Every other failure is a
Expand Down Expand Up @@ -155,17 +157,19 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None =
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}
payload = {**fields, "expires_at": claims["exp"]}
# Checked first, so a version 1 grant is told its version is the problem rather than its first unknown field.
if fields.get("version") != 2:
raise GitInvalidGrant(detail="The git grant is not version 2, the only version the git endpoint accepts.")
try:
grant = msgspec.convert({**fields, "expires_at": claims["exp"]}, type=Grant, strict=True)
grant = msgspec.convert(payload, type=Grant, strict=True)
except msgspec.ValidationError as e:
# The signature matched, so the holder of the private key wrote these claims; telling them why they are
# The signature matched, so the holder of the private key wrote these claims, and telling them why they are
# invalid gives nothing away.
raise GitInvalidGrant(detail=f"The git grant's payload is invalid: {e}") from None

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


Expand Down
Loading
Loading