Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions internal/githubapp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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"`
FallbackOrg string `hcl:"fallback-org,optional" help:"Org whose installation token is used for orgs without their own installation (ensures authenticated rate limits)"`
}

// TokenCacheConfig configures token caching behavior.
Expand Down
33 changes: 30 additions & 3 deletions internal/githubapp/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ type cachedToken struct {

// TokenManager manages GitHub App installation tokens across one or more apps.
type TokenManager struct {
orgToApp map[string]*appState
orgToApp map[string]*appState
fallbackApp *appState
fallbackOrg string
}

func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, error) {
Expand Down Expand Up @@ -93,18 +95,43 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro
return nil, nil //nolint:nilnil
}

return &TokenManager{orgToApp: orgToApp}, nil
tm := &TokenManager{orgToApp: orgToApp}

for _, 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)
}
tm.fallbackApp = app
tm.fallbackOrg = config.FallbackOrg
logger.Info("GitHub App fallback configured",
"fallback_org", config.FallbackOrg,
"app_id", config.AppID)
break
}
}

return tm, nil
}

// 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.
func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, error) {
if tm == nil {
return "", errors.New("token manager not initialized")
}

app, ok := tm.orgToApp[org]
if !ok {
return "", errors.Errorf("no GitHub App configured for org: %s", org)
if tm.fallbackApp == nil {
return "", errors.Errorf("no GitHub App configured for org: %s", org)
}
logging.FromContext(ctx).InfoContext(ctx, "Using fallback org token",
slog.String("requested_org", org),
slog.String("fallback_org", tm.fallbackOrg))
return tm.fallbackApp.getToken(ctx, tm.fallbackOrg)
}

return app.getToken(ctx, org)
Expand Down
58 changes: 58 additions & 0 deletions internal/githubapp/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/alecthomas/assert/v2"

"github.com/block/cachew/internal/githubapp"
"github.com/block/cachew/internal/logging"
)

func generateTestKey(t *testing.T) string {
Expand Down Expand Up @@ -144,6 +145,63 @@ func TestGetTokenForOrgRouting(t *testing.T) {
assert.Contains(t, err.Error(), "no GitHub App configured for org")
}

func TestGetTokenForOrgFallback(t *testing.T) {
keyPath := generateTestKey(t)
logger := 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()
assert.NoError(t, err)

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")
})

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("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()
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")
Expand Down