From bc425d15a27d15f6677e49badeb96ade7902ad0c Mon Sep 17 00:00:00 2001 From: Patrick O'Doherty Date: Thu, 4 Jun 2026 23:35:59 +0000 Subject: [PATCH] github: support multi-org GitHub App installations Replace the single-installation gitBasicAuthTransport with a multiOrgTransport that discovers and caches per-org GitHub App installations. At startup, GET /app/installations enumerates all orgs the App is installed on and pre-populates the cache. On cache miss (new org installed after startup), the transport lazily calls GET /orgs/{org}/installation. The org is extracted from the request URL path (first path segment, lowercased) and matched to the correct installation token, which is set as HTTP basic auth for GitHub's git smart HTTP endpoints. This removes the --github-app-install-id flag; all installations are now discovered dynamically from the GitHub API. Signed-off-by: Patrick O'Doherty --- .github/workflows/test.yml | 31 +++++++ github.go | 183 ++++++++++++++++++++++++++++++++----- github_test.go | 28 ++++++ rogitproxy.go | 9 +- 4 files changed, 228 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 github_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ac48bff --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Configure git + run: | + git config --global user.name "github-actions" + git config --global user.email "github@actions" + + - name: Run tests + run: go test -race -count=1 ./... diff --git a/github.go b/github.go index 4120566..6516584 100644 --- a/github.go +++ b/github.go @@ -5,11 +5,14 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "net/http" "os" "path/filepath" + "strings" + "sync" "time" "github.com/bradleyfalzon/ghinstallation/v2" @@ -17,9 +20,8 @@ import ( ) var ( - gitHubAppID = flag.Int64("github-app-id", 3091475, "GitHub App ID") - gitHubInstallationID = flag.Int64("github-app-install-id", 116358802, "GitHub App installation ID") - setecSecret = flag.String("setec-secret", "prod/rogitproxy/github-app-key-pem", "setec secret name for the GitHub App private key") + gitHubAppID = flag.Int64("github-app-id", 3091475, "GitHub App ID") + setecSecret = flag.String("setec-secret", "prod/rogitproxy/github-app-key-pem", "setec secret name for the GitHub App private key") ) // GitHubAppTransport returns an http.RoundTripper that authenticates @@ -27,48 +29,185 @@ var ( // basic auth (x-access-token:), which is the format GitHub's // git smart HTTP endpoints require. // +// It discovers all installations of the GitHub App at startup and +// caches per-org transports. New org installations are discovered +// lazily on first request. +// // It reads the private key from ~/keys/rogitproxy.pem on disk, // falling back to setec if setecURL is non-empty. -func GitHubAppTransport(ctx context.Context, setecURL string, setecDo func(*http.Request) (*http.Response, error)) (http.RoundTripper, error) { +// +// It returns the transport and the number of org installations +// discovered at startup. +func GitHubAppTransport(ctx context.Context, setecURL string, setecDo func(*http.Request) (*http.Response, error)) (*multiOrgTransport, error) { keyPEM, err := getGitHubAppPrivateKey(ctx, setecURL, setecDo) if err != nil { return nil, err } - appTr, err := ghinstallation.NewAppsTransport(http.DefaultTransport, *gitHubAppID, keyPEM) + appsTr, err := ghinstallation.NewAppsTransport(http.DefaultTransport, *gitHubAppID, keyPEM) if err != nil { return nil, fmt.Errorf("creating GitHub app transport: %w", err) } - itr := ghinstallation.NewFromAppsTransport(appTr, *gitHubInstallationID) - // Verify we can get a token at startup. - if _, err := itr.Token(ctx); err != nil { - return nil, fmt.Errorf("getting initial token: %w", err) + mt := &multiOrgTransport{ + base: http.DefaultTransport, + appsTr: appsTr, + orgTr: make(map[string]*ghinstallation.Transport), } - return &gitBasicAuthTransport{base: http.DefaultTransport, itr: itr}, nil + return mt, nil } -// gitBasicAuthTransport wraps a ghinstallation.Transport and adds -// HTTP basic auth (x-access-token:) to each request. -// This is the format GitHub's git smart HTTP endpoints require, -// as opposed to the "Authorization: token " header that -// ghinstallation uses by default (which works for the REST API -// but not for git HTTP). -type gitBasicAuthTransport struct { - base http.RoundTripper - itr *ghinstallation.Transport +// multiOrgTransport is an http.RoundTripper that routes GitHub API +// requests to the correct GitHub App installation based on the org +// in the request URL path. It caches per-org installation transports +// and lazily discovers new ones on cache miss. +type multiOrgTransport struct { + base http.RoundTripper + appsTr *ghinstallation.AppsTransport + + mu sync.RWMutex + orgTr map[string]*ghinstallation.Transport // lowercase org -> transport } -func (t *gitBasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - tok, err := t.itr.Token(req.Context()) +func (t *multiOrgTransport) RoundTrip(req *http.Request) (*http.Response, error) { + org := orgFromPath(req.URL.Path) + if org == "" { + return nil, fmt.Errorf("cannot determine org from request path %q", req.URL.Path) + } + + itr, err := t.transportForOrg(req.Context(), org) + if err != nil { + return nil, fmt.Errorf("getting transport for org %q: %w", org, err) + } + + tok, err := itr.Token(req.Context()) if err != nil { - return nil, fmt.Errorf("getting GitHub App token: %w", err) + return nil, fmt.Errorf("getting GitHub App token for org %q: %w", org, err) } req = req.Clone(req.Context()) req.SetBasicAuth("x-access-token", tok) return t.base.RoundTrip(req) } +// orgFromPath extracts the GitHub org from a URL path like +// "/tailscale/corp.git/info/refs". It returns the lowercased org +// name, or "" if the path doesn't contain one. +func orgFromPath(path string) string { + path = strings.TrimPrefix(path, "/") + slash := strings.IndexByte(path, '/') + if slash <= 0 { + return "" + } + return strings.ToLower(path[:slash]) +} + +// transportForOrg returns a cached ghinstallation.Transport for the +// given org. On cache miss it discovers the installation via the +// GitHub API and caches the result. +func (t *multiOrgTransport) transportForOrg(ctx context.Context, org string) (*ghinstallation.Transport, error) { + t.mu.RLock() + itr, ok := t.orgTr[org] + t.mu.RUnlock() + if ok { + return itr, nil + } + + // Cache miss — discover the installation for this org. + installID, err := t.findInstallationForOrg(ctx, org) + if err != nil { + return nil, err + } + itr = ghinstallation.NewFromAppsTransport(t.appsTr, installID) + t.mu.Lock() + // Only set if we didn't lose the race + if existing, ok := t.orgTr[org]; !ok { + t.orgTr[org] = itr + } else { + itr = existing + } + t.mu.Unlock() + return itr, nil +} + +// findInstallationForOrg calls GET /orgs/{org}/installation using the +// App JWT to discover the installation ID for the given org. +func (t *multiOrgTransport) findInstallationForOrg(ctx context.Context, org string) (int64, error) { + url := fmt.Sprintf("https://api.github.com/orgs/%s/installation", org) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + resp, err := t.appsTr.RoundTrip(req) + if err != nil { + return 0, fmt.Errorf("GitHub API request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, fmt.Errorf("GitHub App not installed on org %q (HTTP %d)", org, resp.StatusCode) + } + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding installation response: %w", err) + } + if result.ID == 0 { + return 0, fmt.Errorf("no installation ID returned for org %q", org) + } + return result.ID, nil +} + +// discoverAllInstallations calls GET /app/installations to enumerate +// all installations of the GitHub App and pre-populates the org cache. +// It returns the number of installations found. +func (t *multiOrgTransport) discoverAllInstallations(ctx context.Context) (int, error) { + url := "https://api.github.com/app/installations?per_page=100" + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + resp, err := t.appsTr.RoundTrip(req) + if err != nil { + return 0, fmt.Errorf("listing installations: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, fmt.Errorf("listing installations: HTTP %d", resp.StatusCode) + } + + var installations []struct { + ID int64 `json:"id"` + Account struct { + Login string `json:"login"` + } `json:"account"` + } + if err := json.NewDecoder(resp.Body).Decode(&installations); err != nil { + return 0, fmt.Errorf("decoding installations: %w", err) + } + + var verified bool + for _, inst := range installations { + org := strings.ToLower(inst.Account.Login) + if org == "" { + continue + } + itr := ghinstallation.NewFromAppsTransport(t.appsTr, inst.ID) + + // Verify we can get a token for at least one installation. + if !verified { + if _, err := itr.Token(ctx); err != nil { + return 0, fmt.Errorf("getting initial token for org %q (installation %d): %w", org, inst.ID, err) + } + verified = true + } + + t.mu.Lock() + t.orgTr[org] = itr + t.mu.Unlock() + } + return len(installations), nil +} + func getGitHubAppPrivateKeyFromDisk() (_ []byte, ok bool) { if home, err := os.UserHomeDir(); err == nil { if pem, err := os.ReadFile(filepath.Join(home, "keys", "rogitproxy.pem")); err == nil { diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..6a1cf0a --- /dev/null +++ b/github_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import "testing" + +func TestOrgFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/tailscale/corp.git/info/refs", "tailscale"}, + {"/borderzero/border0-cli.git/git-upload-pack", "borderzero"}, + {"/TAILSCALE/Corp.git/info/refs", "tailscale"}, + {"/Org-Name/repo.git/info/refs", "org-name"}, + {"/", ""}, + {"", ""}, + {"/onlyone", ""}, + {"noslash", ""}, + } + for _, tt := range tests { + got := orgFromPath(tt.path) + if got != tt.want { + t.Errorf("orgFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} diff --git a/rogitproxy.go b/rogitproxy.go index 3a7441a..7acc0ca 100644 --- a/rogitproxy.go +++ b/rogitproxy.go @@ -96,11 +96,18 @@ func main() { if err != nil { log.Fatalf("github app auth: %v", err) } + n, err := tr.discoverAllInstallations(context.Background()) + if err != nil { + log.Fatalf("discoverAllInstallations: %v", err) + } + if n == 0 { + log.Fatalf("no GitHub app installations found") + } proxy.HTTPClient = &http.Client{Transport: tr} if srv != nil { proxy.RequireGrants = true } - log.Printf("github app auth enabled (app %d, installation %d)", *gitHubAppID, *gitHubInstallationID) + log.Printf("github app auth enabled (app %d, %d org installations)", *gitHubAppID, n) } // Start HTTP debug/status server.