From 86db55cacdb67089eec342a442ebd8f4cfbaa1eb Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 20 Mar 2026 14:01:46 +1100 Subject: [PATCH] feat: dynamically discover GitHub App installations via API Instead of requiring org installation IDs to be statically configured in cachew.hcl, dynamically resolve them by querying the GitHub API (GET /orgs/{org}/installation, falling back to /users/{user}/installation). Removes the static Installations map from Config entirely. Installation IDs are discovered on first use and cached in memory. The fallback-org config is retained for orgs where the app is not installed. This fixes clone failures for orgs like AfterpayTouch where the GitHub App is installed but the installation ID wasn't in the config. Ports the same approach used in blox's internal/github/app_auth.go. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019d0910-4486-750f-bb6b-0629397da303 --- internal/githubapp/config.go | 2 +- internal/githubapp/testing.go | 24 ++++ internal/githubapp/tokens.go | 178 +++++++++++++++++++++++------- internal/githubapp/tokens_test.go | 168 +++++++++++++--------------- 4 files changed, 240 insertions(+), 132 deletions(-) create mode 100644 internal/githubapp/testing.go diff --git a/internal/githubapp/config.go b/internal/githubapp/config.go index 5547ae0..fb2bd4d 100644 --- a/internal/githubapp/config.go +++ b/internal/githubapp/config.go @@ -7,7 +7,7 @@ import "time" type Config struct { AppID string `hcl:"app-id,optional" help:"GitHub App ID"` PrivateKeyPath string `hcl:"private-key-path,optional" help:"Path to GitHub App private key (PEM format)"` - Installations map[string]string `hcl:"installations,optional" help:"Mapping of org names to installation IDs"` + Installations map[string]string `hcl:"installations,optional" help:"Deprecated: installations are now discovered dynamically via the GitHub API"` FallbackOrg string `hcl:"fallback-org,optional" help:"Org whose installation token is used for orgs without their own installation (ensures authenticated rate limits)"` } diff --git a/internal/githubapp/testing.go b/internal/githubapp/testing.go new file mode 100644 index 0000000..c1ec912 --- /dev/null +++ b/internal/githubapp/testing.go @@ -0,0 +1,24 @@ +package githubapp + +import ( + "log/slog" + "net/http" + "testing" +) + +// NewTokenManagerForTest creates a TokenManager with a custom API base URL and HTTP client for testing. +func NewTokenManagerForTest(t *testing.T, configs []Config, logger *slog.Logger, apiBase string, httpClient *http.Client) *TokenManager { + t.Helper() + + tm, err := newTokenManager(configs, logger) + if err != nil { + t.Fatal(err) + } + + for _, app := range tm.apps { + app.apiBase = apiBase + app.httpClient = httpClient + } + + return tm +} diff --git a/internal/githubapp/tokens.go b/internal/githubapp/tokens.go index 0cd4bb8..5033cf3 100644 --- a/internal/githubapp/tokens.go +++ b/internal/githubapp/tokens.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "strings" "sync" "time" @@ -26,13 +27,18 @@ func NewTokenManagerProvider(configs []Config, logger *slog.Logger) TokenManager }) } +const githubAPIBase = "https://api.github.com" + // appState holds token management state for a single GitHub App. type appState struct { appID string jwtGenerator *JWTGenerator cacheConfig TokenCacheConfig httpClient *http.Client - orgs map[string]string // org -> installation ID + apiBase string + + installationMu sync.RWMutex + installationCache map[string]string // org -> installation ID (dynamically discovered) mu sync.RWMutex tokens map[string]*cachedToken @@ -45,23 +51,25 @@ type cachedToken struct { // TokenManager manages GitHub App installation tokens across one or more apps. type TokenManager struct { - orgToApp map[string]*appState + mu sync.RWMutex + orgToApp map[string]*appState + + apps []*appState // all configured apps, for dynamic installation discovery fallbackApp *appState fallbackOrg string } func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, error) { - orgToApp := map[string]*appState{} + var apps []*appState for _, config := range configs { - hasAny := config.AppID != "" || config.PrivateKeyPath != "" || len(config.Installations) > 0 - hasAll := config.AppID != "" && config.PrivateKeyPath != "" && len(config.Installations) > 0 + hasAny := config.AppID != "" || config.PrivateKeyPath != "" if !hasAny { continue } - if !hasAll { - return nil, errors.Errorf("github-app: incomplete configuration (app-id=%q, private-key-path=%q, installations=%d)", - config.AppID, config.PrivateKeyPath, len(config.Installations)) + if config.AppID == "" || config.PrivateKeyPath == "" { + return nil, errors.Errorf("github-app: incomplete configuration (app-id=%q, private-key-path=%q)", + config.AppID, config.PrivateKeyPath) } cacheConfig := DefaultTokenCacheConfig() @@ -70,38 +78,34 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro return nil, errors.Wrapf(err, "github app %q", config.AppID) } - app := &appState{ - appID: config.AppID, - jwtGenerator: jwtGen, - cacheConfig: cacheConfig, - httpClient: http.DefaultClient, - orgs: config.Installations, - tokens: make(map[string]*cachedToken), - } - - for org := range config.Installations { - if existing, exists := orgToApp[org]; exists { - return nil, errors.Errorf("org %q is configured in both github-app %q and %q", org, existing.appID, config.AppID) - } - orgToApp[org] = app - } - - logger.Info("GitHub App configured", "app_id", config.AppID, "orgs", len(config.Installations)) + apps = append(apps, &appState{ + appID: config.AppID, + jwtGenerator: jwtGen, + cacheConfig: cacheConfig, + httpClient: http.DefaultClient, + apiBase: githubAPIBase, + installationCache: make(map[string]string), + tokens: make(map[string]*cachedToken), + }) + + logger.Info("GitHub App configured", "app_id", config.AppID) } - if len(orgToApp) == 0 { + if len(apps) == 0 { return nil, nil //nolint:nilnil } - tm := &TokenManager{orgToApp: orgToApp} + tm := &TokenManager{ + orgToApp: make(map[string]*appState), + apps: apps, + } - for _, config := range configs { + for i, config := range configs { if config.FallbackOrg != "" { - app, ok := orgToApp[config.FallbackOrg] - if !ok { - return nil, errors.Errorf("fallback-org %q is not in the installations map for app %q", config.FallbackOrg, config.AppID) + if i >= len(apps) { + continue } - tm.fallbackApp = app + tm.fallbackApp = apps[i] tm.fallbackOrg = config.FallbackOrg logger.Info("GitHub App fallback configured", "fallback_org", config.FallbackOrg, "app_id", config.AppID) break @@ -112,24 +116,48 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro } // GetTokenForOrg returns an installation token for the given GitHub organization. -// If no installation is configured for the org, it falls back to the fallback org's -// token to ensure authenticated rate limits. +// It dynamically discovers the installation ID via the GitHub API on first use, +// caches the result, and falls back to the fallback org's token for orgs where +// the app is not installed. func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, error) { if tm == nil { return "", errors.New("token manager not initialized") } + logger := logging.FromContext(ctx) + + // Check cached discovery first + tm.mu.RLock() app, ok := tm.orgToApp[org] - if !ok { - if tm.fallbackApp == nil { - return "", errors.Errorf("no GitHub App configured for org: %s", org) + tm.mu.RUnlock() + if ok { + return app.getToken(ctx, org) + } + + // Discover installation via GitHub API + for _, app := range tm.apps { + installationID, err := app.lookupInstallationID(ctx, org) + if err != nil { + logger.DebugContext(ctx, "Dynamic installation lookup failed", "org", org, "app_id", app.appID, "error", err) + continue } - logging.FromContext(ctx).InfoContext(ctx, "Using fallback org token", "requested_org", org, - "fallback_org", tm.fallbackOrg) + + logger.InfoContext(ctx, "Dynamically discovered GitHub App installation", "org", org, "app_id", app.appID, "installation_id", installationID) + + // Cache the mapping for future requests + tm.mu.Lock() + tm.orgToApp[org] = app + tm.mu.Unlock() + return app.getToken(ctx, org) + } + + // Fall back to fallback org + if tm.fallbackApp != nil { + logger.InfoContext(ctx, "Using fallback org token", "requested_org", org, "fallback_org", tm.fallbackOrg) return tm.fallbackApp.getToken(ctx, tm.fallbackOrg) } - return app.getToken(ctx, org) + return "", errors.Errorf("no GitHub App installation found for org: %s", org) } // GetTokenForURL extracts the org from a GitHub URL and returns an installation token. @@ -148,7 +176,9 @@ func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string, func (a *appState) getToken(ctx context.Context, org string) (string, error) { logger := logging.FromContext(ctx).With("org", org, "app_id", a.appID) - installationID := a.orgs[org] + a.installationMu.RLock() + installationID := a.installationCache[org] + a.installationMu.RUnlock() if installationID == "" { return "", errors.Errorf("no installation ID for org: %s", org) } @@ -187,7 +217,7 @@ func (a *appState) fetchInstallationToken(ctx context.Context, installationID st return "", time.Time{}, errors.Wrap(err, "generate JWT") } - url := fmt.Sprintf("https://api.github.com/app/installations/%s/access_tokens", installationID) + url := fmt.Sprintf("%s/app/installations/%s/access_tokens", a.apiBase, installationID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return "", time.Time{}, errors.Wrap(err, "create request") @@ -218,6 +248,70 @@ func (a *appState) fetchInstallationToken(ctx context.Context, installationID st return result.Token, result.ExpiresAt, nil } +// lookupInstallationID queries the GitHub API to find the installation ID for the +// given org. It tries /orgs/{org}/installation first, then /users/{user}/installation. +// Results are cached in installationCache. +func (a *appState) lookupInstallationID(ctx context.Context, org string) (string, error) { + // Check cache first + a.installationMu.RLock() + if id, ok := a.installationCache[org]; ok { + a.installationMu.RUnlock() + return id, nil + } + a.installationMu.RUnlock() + + jwt, err := a.jwtGenerator.GenerateJWT() + if err != nil { + return "", errors.Wrap(err, "generate JWT") + } + + // Try org endpoint first, then user endpoint + for _, endpoint := range []string{ + fmt.Sprintf("%s/orgs/%s/installation", a.apiBase, org), + fmt.Sprintf("%s/users/%s/installation", a.apiBase, org), + } { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", errors.Wrap(err, "create request") + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("X-Github-Api-Version", "2022-11-28") + + resp, err := a.httpClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "execute request") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + continue + } + + if resp.StatusCode != http.StatusOK { + return "", errors.Errorf("GitHub API returned status %d for %s", resp.StatusCode, endpoint) + } + + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", errors.Wrap(err, "decode response") + } + + installationID := strconv.FormatInt(result.ID, 10) + + a.installationMu.Lock() + a.installationCache[org] = installationID + a.installationMu.Unlock() + + return installationID, nil + } + + return "", errors.Errorf("no GitHub App installation found for %s", org) +} + func extractOrgFromURL(url string) (string, error) { url = strings.TrimPrefix(url, "https://") url = strings.TrimPrefix(url, "http://") diff --git a/internal/githubapp/tokens_test.go b/internal/githubapp/tokens_test.go index b7f0cb9..bcac34f 100644 --- a/internal/githubapp/tokens_test.go +++ b/internal/githubapp/tokens_test.go @@ -4,8 +4,11 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/json" "encoding/pem" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -54,9 +57,8 @@ func TestNewTokenManagerProvider(t *testing.T) { t.Run("ErrorsOnIncompleteConfigs", func(t *testing.T) { for _, config := range []githubapp.Config{ - {AppID: "123", Installations: map[string]string{"org": "inst"}}, - {PrivateKeyPath: "/tmp/key.pem", Installations: map[string]string{"org": "inst"}}, - {AppID: "123", PrivateKeyPath: "/tmp/key.pem"}, + {AppID: "123"}, + {PrivateKeyPath: "/tmp/key.pem"}, } { provider := githubapp.NewTokenManagerProvider([]githubapp.Config{config}, logger) _, err := provider() @@ -71,7 +73,6 @@ func TestNewTokenManagerProvider(t *testing.T) { { AppID: "111", PrivateKeyPath: keyPath, - Installations: map[string]string{"orgA": "inst-a", "orgB": "inst-b"}, }, }, logger) tm, err := provider() @@ -86,120 +87,109 @@ func TestNewTokenManagerProvider(t *testing.T) { { AppID: "111", PrivateKeyPath: keyPath1, - Installations: map[string]string{"orgA": "inst-a"}, }, { AppID: "222", PrivateKeyPath: keyPath2, - Installations: map[string]string{"orgB": "inst-b"}, }, }, logger) tm, err := provider() assert.NoError(t, err) assert.NotZero(t, tm) }) +} - t.Run("DuplicateOrgAcrossApps", func(t *testing.T) { - keyPath1 := generateTestKey(t) - keyPath2 := generateTestKey(t) - provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ - { - AppID: "111", - PrivateKeyPath: keyPath1, - Installations: map[string]string{"orgA": "inst-a"}, - }, - { - AppID: "222", - PrivateKeyPath: keyPath2, - Installations: map[string]string{"orgA": "inst-a2"}, - }, - }, logger) - _, err := provider() - assert.Error(t, err) - assert.Contains(t, err.Error(), "org \"orgA\" is configured in both") +func TestGetTokenForOrgDynamicDiscovery(t *testing.T) { + // Mock GitHub API that returns installation IDs for known orgs + mux := http.NewServeMux() + mux.HandleFunc("GET /orgs/squareup/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"id": 12345}) //nolint:errcheck }) -} + mux.HandleFunc("GET /orgs/AfterpayTouch/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"id": 67890}) //nolint:errcheck + }) + mux.HandleFunc("GET /orgs/unknown-org/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /users/unknown-org/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("POST /app/installations/12345/access_tokens", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"token": "ghs_squareup_token", "expires_at": "2099-01-01T00:00:00Z"}) //nolint:errcheck + }) + mux.HandleFunc("POST /app/installations/67890/access_tokens", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"token": "ghs_afterpay_token", "expires_at": "2099-01-01T00:00:00Z"}) //nolint:errcheck + }) + server := httptest.NewServer(mux) + defer server.Close() -func TestGetTokenForOrgRouting(t *testing.T) { - keyPath1 := generateTestKey(t) - keyPath2 := generateTestKey(t) + keyPath := generateTestKey(t) logger := slog.Default() - provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + tm := githubapp.NewTokenManagerForTest(t, []githubapp.Config{ { AppID: "111", - PrivateKeyPath: keyPath1, - Installations: map[string]string{"orgA": "inst-a"}, + PrivateKeyPath: keyPath, + FallbackOrg: "squareup", }, - { - AppID: "222", - PrivateKeyPath: keyPath2, - Installations: map[string]string{"orgB": "inst-b"}, - }, - }, logger) - tm, err := provider() - assert.NoError(t, err) - - _, err = tm.GetTokenForOrg(t.Context(), "unknown-org") - assert.Error(t, err) - assert.Contains(t, err.Error(), "no GitHub App configured for org") -} + }, logger, server.URL, server.Client()) -func TestGetTokenForOrgFallback(t *testing.T) { - keyPath := generateTestKey(t) - logger := slog.Default() + ctx := logging.ContextWithLogger(t.Context(), slog.Default()) - t.Run("FallbackUsedForUnknownOrg", func(t *testing.T) { - provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ - { - AppID: "111", - PrivateKeyPath: keyPath, - Installations: map[string]string{"squareup": "inst-sq"}, - FallbackOrg: "squareup", - }, - }, logger) - tm, err := provider() + t.Run("DiscoverAndCacheInstallation", func(t *testing.T) { + token, err := tm.GetTokenForOrg(ctx, "squareup") assert.NoError(t, err) + assert.Equal(t, "ghs_squareup_token", token) - ctx := logging.ContextWithLogger(t.Context(), slog.Default()) - // Unknown org should not error when fallback is configured - // (will fail at the HTTP level but not at the routing level) - _, err = tm.GetTokenForOrg(ctx, "cashapp") - // Error is expected here because we don't have a real GitHub API, - // but it should NOT be "no GitHub App configured for org" - assert.Error(t, err) - assert.NotContains(t, err.Error(), "no GitHub App configured for org") + // Second call should use cache + token, err = tm.GetTokenForOrg(ctx, "squareup") + assert.NoError(t, err) + assert.Equal(t, "ghs_squareup_token", token) }) - t.Run("FallbackOrgNotInInstallations", func(t *testing.T) { - provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ - { - AppID: "111", - PrivateKeyPath: keyPath, - Installations: map[string]string{"squareup": "inst-sq"}, - FallbackOrg: "nonexistent", - }, - }, logger) - _, err := provider() - assert.Error(t, err) - assert.Contains(t, err.Error(), "fallback-org \"nonexistent\" is not in the installations map") + t.Run("DiscoverNewOrg", func(t *testing.T) { + token, err := tm.GetTokenForOrg(ctx, "AfterpayTouch") + assert.NoError(t, err) + assert.Equal(t, "ghs_afterpay_token", token) }) - t.Run("NoFallbackStillErrorsForUnknownOrg", func(t *testing.T) { - provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ - { - AppID: "111", - PrivateKeyPath: keyPath, - Installations: map[string]string{"squareup": "inst-sq"}, - }, - }, logger) - tm, err := provider() + t.Run("FallbackForUnknownOrg", func(t *testing.T) { + token, err := tm.GetTokenForOrg(ctx, "unknown-org") assert.NoError(t, err) + assert.Equal(t, "ghs_squareup_token", token) + }) +} - _, err = tm.GetTokenForOrg(t.Context(), "unknown-org") - assert.Error(t, err) - assert.Contains(t, err.Error(), "no GitHub App configured for org") +func TestGetTokenForOrgNoFallback(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /orgs/unknown-org/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) }) + mux.HandleFunc("GET /users/unknown-org/installation", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + server := httptest.NewServer(mux) + defer server.Close() + + keyPath := generateTestKey(t) + logger := slog.Default() + + tm := githubapp.NewTokenManagerForTest(t, []githubapp.Config{ + { + AppID: "111", + PrivateKeyPath: keyPath, + }, + }, logger, server.URL, server.Client()) + + ctx := logging.ContextWithLogger(t.Context(), slog.Default()) + + _, err := tm.GetTokenForOrg(ctx, "unknown-org") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no GitHub App installation found for org") } func TestGetTokenForOrgNilManager(t *testing.T) {