diff --git a/cachew.hcl b/cachew.hcl index cc217cf..249c1ae 100644 --- a/cachew.hcl +++ b/cachew.hcl @@ -11,12 +11,11 @@ log { git-clone {} -github-app { - # Uncomment and add: - # app-id = "app-id-value" (Can also be passed via setting envar CACHEW_GITHUB_APP_APP_ID) - # private-key-path = "private-key-path-value" (Can also be passed via envar CACHEW_GITHUB_APP_PRIVATE_KEY_PATH) - # installations = { "myorg" : "installation-id" } -} +# github-app "my-app" { +# app-id = "app-id-value" +# private-key-path = "private-key-path-value" +# installations = { "myorg" : "installation-id" } +# } metrics {} diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index 99187b3..0a5da59 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -33,14 +33,14 @@ import ( ) type GlobalConfig struct { - State string `hcl:"state" default:"./state" help:"Base directory for all state (git mirrors, cache, etc.)."` - Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."` - URL string `hcl:"url" default:"http://127.0.0.1:8080/" help:"Base URL for cachewd."` - SchedulerConfig jobscheduler.Config `hcl:"scheduler,block"` - LoggingConfig logging.Config `hcl:"log,block"` - MetricsConfig metrics.Config `hcl:"metrics,block"` - GitCloneConfig gitclone.Config `hcl:"git-clone,block"` - GithubAppConfig githubapp.Config `embed:"" hcl:"github-app,block,optional" prefix:"github-app-"` + State string `hcl:"state" default:"./state" help:"Base directory for all state (git mirrors, cache, etc.)."` + Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."` + URL string `hcl:"url" default:"http://127.0.0.1:8080/" help:"Base URL for cachewd."` + SchedulerConfig jobscheduler.Config `hcl:"scheduler,block"` + LoggingConfig logging.Config `hcl:"log,block"` + MetricsConfig metrics.Config `hcl:"metrics,block"` + GitCloneConfig gitclone.Config `hcl:"git-clone,block"` + GithubAppConfigs []githubapp.Config `hcl:"github-app,block,optional"` } type CLI struct { @@ -68,7 +68,7 @@ func main() { reaper.Start(ctx) // Start initialising - tokenManagerProvider := githubapp.NewTokenManagerProvider(globalConfig.GithubAppConfig, logger) + tokenManagerProvider := githubapp.NewTokenManagerProvider(globalConfig.GithubAppConfigs, logger) managerProvider := gitclone.NewManagerProvider(ctx, globalConfig.GitCloneConfig, func() (gitclone.CredentialProvider, error) { return tokenManagerProvider() }) diff --git a/internal/githubapp/config.go b/internal/githubapp/config.go index ee3d03b..ddcb1a6 100644 --- a/internal/githubapp/config.go +++ b/internal/githubapp/config.go @@ -1,74 +1,23 @@ // Package githubapp provides GitHub App authentication and token management. package githubapp -import ( - "log/slog" - "time" - - "github.com/alecthomas/errors" -) +import "time" +// Config represents the configuration for a single GitHub App. type Config struct { + Name string `hcl:"name,label" help:"Name for this GitHub App configuration."` 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 maps organization names to GitHub App installation IDs. -type Installations struct { - appID string - privateKeyPath string - orgs map[string]string -} - -// NewInstallations creates an Installations instance from config. -func NewInstallations(config Config, logger *slog.Logger) (*Installations, error) { - if len(config.Installations) == 0 { - return nil, errors.New("installations is required") - } - - logger.Info("GitHub App config initialized", - "app_id", config.AppID, - "private_key_path", config.PrivateKeyPath, - "installations", len(config.Installations)) - - return &Installations{ - appID: config.AppID, - privateKeyPath: config.PrivateKeyPath, - orgs: config.Installations, - }, nil -} - -func (i *Installations) IsConfigured() bool { - return i != nil && i.appID != "" && i.privateKeyPath != "" && len(i.orgs) > 0 -} - -func (i *Installations) GetInstallationID(org string) string { - if i == nil || i.orgs == nil { - return "" - } - return i.orgs[org] -} - -func (i *Installations) AppID() string { - if i == nil { - return "" - } - return i.appID -} - -func (i *Installations) PrivateKeyPath() string { - if i == nil { - return "" - } - return i.privateKeyPath -} - +// TokenCacheConfig configures token caching behavior. type TokenCacheConfig struct { RefreshBuffer time.Duration // How early to refresh before expiration JWTExpiration time.Duration // GitHub allows max 10 minutes } +// DefaultTokenCacheConfig returns default token cache configuration. func DefaultTokenCacheConfig() TokenCacheConfig { return TokenCacheConfig{ RefreshBuffer: 5 * time.Minute, diff --git a/internal/githubapp/tokens.go b/internal/githubapp/tokens.go index 9eefaec..2c92e96 100644 --- a/internal/githubapp/tokens.go +++ b/internal/githubapp/tokens.go @@ -18,27 +18,21 @@ import ( // TokenManagerProvider is a function that lazily creates a singleton TokenManager. type TokenManagerProvider func() (*TokenManager, error) -// NewTokenManagerProvider creates a provider that lazily initializes a TokenManager. -func NewTokenManagerProvider(config Config, logger *slog.Logger) TokenManagerProvider { +// NewTokenManagerProvider creates a provider that lazily initializes a TokenManager +// from one or more GitHub App configurations. +func NewTokenManagerProvider(configs []Config, logger *slog.Logger) TokenManagerProvider { return sync.OnceValues(func() (*TokenManager, error) { - if config.AppID == "" || config.PrivateKeyPath == "" || len(config.Installations) == 0 { - return nil, nil // Not configured, return nil without error - } - - installations, err := NewInstallations(config, logger) - if err != nil { - return nil, errors.Wrap(err, "create installations") - } - - return NewTokenManager(installations, DefaultTokenCacheConfig()) + return newTokenManager(configs, logger) }) } -type TokenManager struct { - installations *Installations - cacheConfig TokenCacheConfig - jwtGenerator *JWTGenerator - httpClient *http.Client +// appState holds token management state for a single GitHub App. +type appState struct { + name string + jwtGenerator *JWTGenerator + cacheConfig TokenCacheConfig + httpClient *http.Client + orgs map[string]string // org -> installation ID mu sync.RWMutex tokens map[string]*cachedToken @@ -49,41 +43,94 @@ type cachedToken struct { expiresAt time.Time } -func NewTokenManager(installations *Installations, cacheConfig TokenCacheConfig) (*TokenManager, error) { - if !installations.IsConfigured() { - return nil, errors.New("GitHub App not configured") +// TokenManager manages GitHub App installation tokens across one or more apps. +type TokenManager struct { + orgToApp map[string]*appState +} + +func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, error) { + orgToApp := map[string]*appState{} + + for _, config := range configs { + if config.AppID == "" || config.PrivateKeyPath == "" || len(config.Installations) == 0 { + continue + } + + cacheConfig := DefaultTokenCacheConfig() + jwtGen, err := NewJWTGenerator(config.AppID, config.PrivateKeyPath, cacheConfig.JWTExpiration) + if err != nil { + return nil, errors.Wrapf(err, "github app %q", config.Name) + } + + app := &appState{ + name: config.Name, + 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.name, config.Name) + } + orgToApp[org] = app + } + + logger.Info("GitHub App configured", + "name", config.Name, + "app_id", config.AppID, + "orgs", len(config.Installations)) } - jwtGenerator, err := NewJWTGenerator(installations.AppID(), installations.PrivateKeyPath(), cacheConfig.JWTExpiration) - if err != nil { - return nil, errors.Wrap(err, "create JWT generator") + if len(orgToApp) == 0 { + return nil, nil //nolint:nilnil } - return &TokenManager{ - installations: installations, - cacheConfig: cacheConfig, - jwtGenerator: jwtGenerator, - httpClient: http.DefaultClient, - tokens: make(map[string]*cachedToken), - }, nil + return &TokenManager{orgToApp: orgToApp}, nil } +// GetTokenForOrg returns an installation token for the given GitHub organization. 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).With(slog.String("org", org)) - installationID := tm.installations.GetInstallationID(org) + app, ok := tm.orgToApp[org] + if !ok { + return "", errors.Errorf("no GitHub App configured for org: %s", org) + } + + return app.getToken(ctx, org) +} + +// GetTokenForURL extracts the org from a GitHub URL and returns an installation token. +func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string, error) { + if tm == nil { + return "", errors.New("token manager not initialized") + } + org, err := extractOrgFromURL(url) + if err != nil { + return "", err + } + + return tm.GetTokenForOrg(ctx, org) +} + +func (a *appState) getToken(ctx context.Context, org string) (string, error) { + logger := logging.FromContext(ctx).With(slog.String("org", org), slog.String("app", a.name)) + + installationID := a.orgs[org] if installationID == "" { - return "", errors.Errorf("no GitHub App installation configured for org: %s", org) + return "", errors.Errorf("no installation ID for org: %s", org) } - tm.mu.RLock() - cached, exists := tm.tokens[org] - tm.mu.RUnlock() + a.mu.RLock() + cached, exists := a.tokens[org] + a.mu.RUnlock() - if exists && time.Now().Add(tm.cacheConfig.RefreshBuffer).Before(cached.expiresAt) { + if exists && time.Now().Add(a.cacheConfig.RefreshBuffer).Before(cached.expiresAt) { logger.DebugContext(ctx, "Using cached GitHub App token") return cached.token, nil } @@ -91,17 +138,17 @@ func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, logger.DebugContext(ctx, "Fetching new GitHub App installation token", slog.String("installation_id", installationID)) - token, expiresAt, err := tm.fetchInstallationToken(ctx, installationID) + token, expiresAt, err := a.fetchInstallationToken(ctx, installationID) if err != nil { return "", errors.Wrap(err, "fetch installation token") } - tm.mu.Lock() - tm.tokens[org] = &cachedToken{ + a.mu.Lock() + a.tokens[org] = &cachedToken{ token: token, expiresAt: expiresAt, } - tm.mu.Unlock() + a.mu.Unlock() logger.InfoContext(ctx, "GitHub App token refreshed", slog.Time("expires_at", expiresAt)) @@ -109,20 +156,8 @@ func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, return token, nil } -func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string, error) { - if tm == nil { - return "", errors.New("token manager not initialized") - } - org, err := extractOrgFromURL(url) - if err != nil { - return "", err - } - - return tm.GetTokenForOrg(ctx, org) -} - -func (tm *TokenManager) fetchInstallationToken(ctx context.Context, installationID string) (string, time.Time, error) { - jwt, err := tm.jwtGenerator.GenerateJWT() +func (a *appState) fetchInstallationToken(ctx context.Context, installationID string) (string, time.Time, error) { + jwt, err := a.jwtGenerator.GenerateJWT() if err != nil { return "", time.Time{}, errors.Wrap(err, "generate JWT") } @@ -137,7 +172,7 @@ func (tm *TokenManager) fetchInstallationToken(ctx context.Context, installation req.Header.Set("Authorization", "Bearer "+jwt) req.Header.Set("X-Github-Api-Version", "2022-11-28") - resp, err := tm.httpClient.Do(req) + resp, err := a.httpClient.Do(req) if err != nil { return "", time.Time{}, errors.Wrap(err, "execute request") } diff --git a/internal/githubapp/tokens_test.go b/internal/githubapp/tokens_test.go new file mode 100644 index 0000000..e742803 --- /dev/null +++ b/internal/githubapp/tokens_test.go @@ -0,0 +1,155 @@ +package githubapp_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/githubapp" +) + +func generateTestKey(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + keyBytes := x509.MarshalPKCS1PrivateKey(key) + pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes} + + path := filepath.Join(t.TempDir(), "test-key.pem") + f, err := os.Create(path) + assert.NoError(t, err) + defer f.Close() + + assert.NoError(t, pem.Encode(f, pemBlock)) + return path +} + +func TestNewTokenManagerProvider(t *testing.T) { + logger := slog.Default() + + t.Run("EmptyConfigs", func(t *testing.T) { + provider := githubapp.NewTokenManagerProvider(nil, logger) + tm, err := provider() + assert.NoError(t, err) + assert.Zero(t, tm) + }) + + t.Run("SkipsIncompleteConfigs", func(t *testing.T) { + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + {Name: "missing-key", AppID: "123", Installations: map[string]string{"org": "inst"}}, + {Name: "missing-id", PrivateKeyPath: "/tmp/key.pem", Installations: map[string]string{"org": "inst"}}, + {Name: "missing-installations", AppID: "123", PrivateKeyPath: "/tmp/key.pem"}, + }, logger) + tm, err := provider() + assert.NoError(t, err) + assert.Zero(t, tm) + }) + + t.Run("SingleApp", func(t *testing.T) { + keyPath := generateTestKey(t) + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + Name: "app1", + AppID: "111", + PrivateKeyPath: keyPath, + Installations: map[string]string{"orgA": "inst-a", "orgB": "inst-b"}, + }, + }, logger) + tm, err := provider() + assert.NoError(t, err) + assert.NotZero(t, tm) + }) + + t.Run("MultipleApps", func(t *testing.T) { + keyPath1 := generateTestKey(t) + keyPath2 := generateTestKey(t) + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + Name: "app1", + AppID: "111", + PrivateKeyPath: keyPath1, + Installations: map[string]string{"orgA": "inst-a"}, + }, + { + Name: "app2", + 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{ + { + Name: "app1", + AppID: "111", + PrivateKeyPath: keyPath1, + Installations: map[string]string{"orgA": "inst-a"}, + }, + { + Name: "app2", + 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 TestGetTokenForOrgRouting(t *testing.T) { + keyPath1 := generateTestKey(t) + keyPath2 := generateTestKey(t) + logger := slog.Default() + + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + Name: "app1", + AppID: "111", + PrivateKeyPath: keyPath1, + Installations: map[string]string{"orgA": "inst-a"}, + }, + { + Name: "app2", + 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") +} + +func TestGetTokenForOrgNilManager(t *testing.T) { + var tm *githubapp.TokenManager + _, err := tm.GetTokenForOrg(t.Context(), "any") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +} + +func TestGetTokenForURLNilManager(t *testing.T) { + var tm *githubapp.TokenManager + _, err := tm.GetTokenForURL(t.Context(), "https://github.com/org/repo") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +}