From 4e9b3b57d6cbe4c13dd8a7316e94e6e3bb6ae2a8 Mon Sep 17 00:00:00 2001 From: pdewilde Date: Wed, 29 Jul 2026 15:55:10 -0700 Subject: [PATCH 1/5] feat(server): add support for minting enterprise-scoped installation access tokens --- pkg/config/config_loader_test.go | 4 ++ pkg/server/config.go | 8 +++ pkg/server/runner.go | 2 +- pkg/server/source/github.go | 75 ++++++++++++++++++++++++++ pkg/server/source/source_system.go | 4 ++ pkg/server/token_minter.go | 85 +++++++++++++++++++++++++---- pkg/server/token_minter_test.go | 87 +++++++++++++++++++++++++++--- 7 files changed, 247 insertions(+), 18 deletions(-) diff --git a/pkg/config/config_loader_test.go b/pkg/config/config_loader_test.go index 878f2a8..00b619d 100644 --- a/pkg/config/config_loader_test.go +++ b/pkg/config/config_loader_test.go @@ -407,6 +407,10 @@ func (s *testSourceSystem) MintAccessToken(ctx context.Context, org, repo string return "token", nil } +func (s *testSourceSystem) MintEnterpriseAccessToken(ctx context.Context, enterprise string, permissions map[string]string) (string, error) { + return "token", nil +} + func (s *testSourceSystem) RetrieveFileContents(ctx context.Context, org, repo, filePath, ref string) ([]byte, error) { return []byte{}, nil } diff --git a/pkg/server/config.go b/pkg/server/config.go index 7a95d72..41f5961 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -53,6 +53,7 @@ type Config struct { GitHubRequestRetry404 bool GitHubRequestRetry422 bool EnforceReadOnly bool + EnterpriseConfigOrgs []string } // LogValue implements slog.LogValuer and returns a grouped value @@ -240,6 +241,13 @@ func (cfg *Config) ToFlags(set *cli.FlagSet) *cli.FlagSet { Default: []string{config.GitHubIssuer, config.GoogleIssuer}, }) + f.StringSliceVar(&cli.StringSliceVar{ + Name: "enterprise-config-orgs", + Target: &cfg.EnterpriseConfigOrgs, + EnvVar: "ENTERPRISE_CONFIG_ORGS", + Usage: `The list of organizations authorized to define enterprise-scoped token permissions. If empty, enterprise-scoped tokens cannot be minted from organization configuration files.`, + }) + f.Uint64Var(&cli.Uint64Var{ Name: "github-request-max-retries", Target: &cfg.GitHubRequestMaxRetries, diff --git a/pkg/server/runner.go b/pkg/server/runner.go index 3c4a9b1..0c6eba3 100644 --- a/pkg/server/runner.go +++ b/pkg/server/runner.go @@ -85,7 +85,7 @@ func Run(ctx context.Context, cfg *Config) error { jwkResolver := NewOIDCResolver(ctx, cfg.IssuerAllowlist, cfg.JWKSCacheDuration) // Create the Router for the token minting server. - tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}, cfg.EnforceReadOnly) + tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}, cfg.EnforceReadOnly, cfg.EnterpriseConfigOrgs) if err != nil { return fmt.Errorf("failed to start token mint server: %w", err) } diff --git a/pkg/server/source/github.go b/pkg/server/source/github.go index 323bdf3..02ebe66 100644 --- a/pkg/server/source/github.go +++ b/pkg/server/source/github.go @@ -17,6 +17,7 @@ package source import ( "context" "crypto" + "encoding/json" "errors" "fmt" "io" @@ -149,6 +150,80 @@ func (g *gitHubSourceSystem) MintAccessToken(ctx context.Context, org, repo stri return accessToken, nil } +func (g *gitHubSourceSystem) installationForEnterprise(ctx context.Context, app *githubauth.App, enterprise string) (*githubauth.AppInstallation, error) { + jwtToken, err := app.AppToken() + if err != nil { + return nil, fmt.Errorf("failed to generate app JWT: %w", err) + } + + baseURL := g.baseURL + if baseURL == "" { + baseURL = "https://api.github.com" + } + url := strings.TrimSuffix(baseURL, "/") + "/enterprises/" + enterprise + "/installation" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwtToken) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := g.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get enterprise installation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d getting enterprise installation", resp.StatusCode) + } + + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode enterprise installation response: %w", err) + } + + return app.InstallationForID(ctx, fmt.Sprint(result.ID)) +} + +// MintEnterpriseAccessToken implements SourceSystem. +func (g *gitHubSourceSystem) MintEnterpriseAccessToken(ctx context.Context, enterprise string, permissions map[string]string) (string, error) { + var errs []error + var installation *githubauth.AppInstallation + var err error + + logger := logging.FromContext(ctx) + for _, app := range g.apps { + installation, err = g.installationForEnterprise(ctx, app, enterprise) + if err != nil { + errs = append(errs, err) + } else { + break + } + } + if len(errs) != 0 { + return "", fmt.Errorf("errors retrieving GitHub enterprise installation: %w", errors.Join(errs...)) + } + + allRepoRequest := &githubauth.TokenRequestAllRepos{Permissions: map[string]string{}} + if permissions != nil { + allRepoRequest.Permissions = permissions + } + + logger.InfoContext(ctx, "sending request for enterprise to GitHub", "request", allRepoRequest) + accessToken, err := installation.AccessTokenAllRepos(ctx, allRepoRequest) + if err != nil { + if strings.Contains(err.Error(), "invalid http response status (expected 404 to be 201):") || strings.Contains(err.Error(), "invalid http response status (expected 422 to be 201):") { + logger.WarnContext(ctx, "error generating GitHub enterprise access token", "error", err) + return "", nil + } + return "", fmt.Errorf("error generating GitHub enterprise access token: %w", err) + } + return accessToken, nil +} + // RetrieveFileContents implements SourceSystem. func (g *gitHubSourceSystem) RetrieveFileContents(ctx context.Context, org, repo, filePath, ref string) ([]byte, error) { logger := logging.FromContext(ctx) diff --git a/pkg/server/source/source_system.go b/pkg/server/source/source_system.go index 2bbfbfc..439f4a3 100644 --- a/pkg/server/source/source_system.go +++ b/pkg/server/source/source_system.go @@ -21,6 +21,10 @@ type System interface { // the specified permissions. MintAccessToken(ctx context.Context, org, repo string, repositories []string, permissions map[string]string) (string, error) + // MintEnterpriseAccessToken generates a new access token on behalf of the enterprise with + // the specified permissions. + MintEnterpriseAccessToken(ctx context.Context, enterprise string, permissions map[string]string) (string, error) + // RetrieveFileContents gets the contents of the file at filePath with the specified ref // from the org/repo. RetrieveFileContents(ctx context.Context, org, repo, filePath, ref string) ([]byte, error) diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index b4322e6..b12f0b6 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -42,10 +42,11 @@ const ( // TokenMinterServer is the implementation of an HTTP server that exchanges // a GitHub OIDC token for a GitHub application token with eleveated privlidges. type TokenMinterServer struct { - sourceSystem source.System - configStore config.ConfigEvaluator - parser *JWTParser - enforceReadOnly bool + sourceSystem source.System + configStore config.ConfigEvaluator + parser *JWTParser + enforceReadOnly bool + enterpriseConfigOrgs []string } // TokenRequest is a struct that contains the list of repositories and the @@ -53,6 +54,7 @@ type TokenMinterServer struct { // installation access token. type TokenRequest struct { OrgName string `json:"org_name"` + Enterprise string `json:"enterprise,omitempty"` Repositories []string `json:"repositories"` Permissions map[string]string `json:"permissions"` Scope string `json:"scope"` @@ -69,12 +71,13 @@ type apiResponse struct { // NewRouter creates a new HTTP server implementation that will exchange // a GitHub OIDC token for a GitHub application token with eleveated privlidges. -func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser, enforceReadOnly bool) (*TokenMinterServer, error) { +func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser, enforceReadOnly bool, enterpriseConfigOrgs []string) (*TokenMinterServer, error) { return &TokenMinterServer{ - sourceSystem: sourceSystem, - configStore: configStore, - parser: parser, - enforceReadOnly: enforceReadOnly, + sourceSystem: sourceSystem, + configStore: configStore, + parser: parser, + enforceReadOnly: enforceReadOnly, + enterpriseConfigOrgs: enterpriseConfigOrgs, }, nil } @@ -156,6 +159,70 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { return apiError } + // If enterprise is requested, mint an enterprise-scoped access token directly. + if request.Enterprise != "" { + if request.OrgName != "" || len(request.Repositories) > 0 { + return &apiResponse{ + http.StatusBadRequest, + "request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed", + errors.New("request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed"), + } + } + if len(s.enterpriseConfigOrgs) == 0 { + return &apiResponse{ + http.StatusForbidden, + "enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes", + errors.New("enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes"), + } + } + + var scope *config.Scope + var source string + var err error + for _, org := range s.enterpriseConfigOrgs { + scope, source, err = s.configStore.Eval(ctx, org, claims.ParsedRepoName, request.Scope, claims.asMap()) + if err == nil && scope != nil { + break + } + } + if err != nil { + return &apiResponse{ + http.StatusInternalServerError, + fmt.Sprintf("requested scope %q is not found", request.Scope), + fmt.Errorf("error reading configuration from configuration store with config_source %s: %w", source, err), + } + } + if scope == nil { + return &apiResponse{http.StatusForbidden, fmt.Sprintf("no permissions available for scope %q", request.Scope), nil} + } + if len(request.Permissions) == 0 { + if _, ok := scope.Permissions["*"]; ok { + request.Permissions = nil + } else { + request.Permissions = scope.Permissions + } + } + if err = validatePermissions(scope.Permissions, request.Permissions); err != nil { + return &apiResponse{http.StatusForbidden, "requested permissions are not authorized for this scope", err} + } + if s.enforceReadOnly { + request.Permissions = map[string]string{ + "contents": "read", + "metadata": "read", + } + } + logger.InfoContext(ctx, "generating enterprise token", + "claims", claims, + "enterprise", request.Enterprise, + "request_permissions", request.Permissions, + ) + accessToken, err := s.sourceSystem.MintEnterpriseAccessToken(ctx, request.Enterprise, request.Permissions) + if err != nil { + return &apiResponse{http.StatusInternalServerError, "error generating enterprise access token", fmt.Errorf("error generating enterprise access token: %w", err)} + } + return &apiResponse{http.StatusOK, accessToken, nil} + } + // Determine the org name to be used for the request requestOrgName, apiError := validateOrgName(request.OrgName, claims.ParsedOrgName) if apiError != nil { diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 7e3a333..3697afe 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -100,6 +100,12 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { mux.Handle("GET /orgs/abcxyz/installation", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"access_tokens_url": "http://%s/app/installations/123/access_tokens"}`, r.Host) })) + mux.Handle("GET /enterprises/alphabet/installation", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"id": 123}`) + })) + mux.Handle("GET /app/installations/123", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"access_tokens_url": "http://%s/app/installations/123/access_tokens"}`, r.Host) + })) mux.Handle("POST /app/installations/123/access_tokens", http.HandlerFunc(handleAccessTokenRequest)) mux.Handle("GET /orgs/org1/installation", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -129,13 +135,14 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { }) cases := []struct { - name string - req *http.Request - expCode int - expResp string - expErr string - resolver mockJwksResolver - enforceReadOnly bool + name string + req *http.Request + expCode int + expResp string + expErr string + resolver mockJwksResolver + enforceReadOnly bool + enterpriseConfigOrgs []string }{ { name: "no_token_header", @@ -509,6 +516,70 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { expCode: 200, expResp: "", }, + { + name: "happy_path_enterprise_token", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"minty_error_test", "enterprise":"alphabet"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + enterpriseConfigOrgs: []string{"org1"}, + expCode: 200, + expResp: "", + }, + { + name: "unhappy_path_enterprise_token_not_enabled", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"test", "enterprise":"alphabet"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + expCode: 403, + expResp: "enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes", + expErr: "enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes", + }, + { + name: "unhappy_path_enterprise_token_with_org_or_repos", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"test", "enterprise":"alphabet", "org_name":"org1"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + enterpriseConfigOrgs: []string{"org1"}, + expCode: 400, + expResp: "request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed", + expErr: "request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed", + }, { name: "unhappy_path_mixing_all_and_specific_repos", req: func() *http.Request { @@ -598,7 +669,7 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { t.Fatal(err) } - server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}, tc.enforceReadOnly) + server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}, tc.enforceReadOnly, tc.enterpriseConfigOrgs) if err != nil { t.Fatal(err) } From 81469cbcb1562267cd3def1bd1be402f58a1583f Mon Sep 17 00:00:00 2001 From: pdewilde Date: Wed, 29 Jul 2026 16:46:28 -0700 Subject: [PATCH 2/5] refactor(server): encapsulate enterprise scope evaluation and use go-github client --- pkg/config/config_evaluator.go | 28 +++++++++++++++-- pkg/server/runner.go | 3 +- pkg/server/source/github.go | 38 ++++++++++------------ pkg/server/token_minter.go | 55 ++++++++++++-------------------- pkg/server/token_minter_test.go | 56 +++++++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 62 deletions(-) diff --git a/pkg/config/config_evaluator.go b/pkg/config/config_evaluator.go index 8e37c47..7d490a4 100644 --- a/pkg/config/config_evaluator.go +++ b/pkg/config/config_evaluator.go @@ -43,13 +43,17 @@ type ConfigEvaluator interface { // was being evaluated when an error occurred. This could be a local file path or a remote // system such as GitHub. Eval(ctx context.Context, org, repo, scope string, token interface{}) (*Scope, string, error) + // EvalEnterprise looks for an enterprise configuration across the allowlisted organizations + // that contains the requested scope. + EvalEnterprise(ctx context.Context, scope string, token interface{}) (*Scope, string, error) } type configEvaluator struct { - loaders []ConfigFileLoader + loaders []ConfigFileLoader + enterpriseConfigOrgs []string } -func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, orgConfigRepo, orgConfigPath, ref string, sourceSystem source.System) (*configEvaluator, error) { +func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, orgConfigRepo, orgConfigPath, ref string, sourceSystem source.System, enterpriseConfigOrgs []string) (*configEvaluator, error) { // create an environment to compile any cel expressions env, err := cel.NewEnv( cel.Variable(AssertionKey, cel.DynType), @@ -82,6 +86,7 @@ func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, inRepoLoader, orgLoader, }, + enterpriseConfigOrgs: enterpriseConfigOrgs, }, nil } @@ -117,3 +122,22 @@ func (l *configEvaluator) Eval(ctx context.Context, org, repo, scope string, tok } return nil, fmt.Sprintf("%s/%s", org, repo), fmt.Errorf("error reading configuration, exhausted all possible source locations, failed to locate scope [%s] for repository [%s/%s].\nEvaluation results:\n%s", scope, org, repo, strings.Join(failureReasons, "\n")) } + +func (l *configEvaluator) EvalEnterprise(ctx context.Context, scope string, token interface{}) (*Scope, string, error) { + if len(l.enterpriseConfigOrgs) == 0 { + return nil, "", fmt.Errorf("enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes") + } + + var failureReasons []string + for _, org := range l.enterpriseConfigOrgs { + s, source, err := l.Eval(ctx, org, "", scope, token) + if err == nil && s != nil { + return s, source, nil + } + if err != nil { + failureReasons = append(failureReasons, fmt.Sprintf("[%s]: %v", org, err)) + } + } + + return nil, "", fmt.Errorf("no permissions available for enterprise scope %q in allowlisted organizations. Evaluation errors:\n%s", scope, strings.Join(failureReasons, "\n")) +} diff --git a/pkg/server/runner.go b/pkg/server/runner.go index 0c6eba3..1e14968 100644 --- a/pkg/server/runner.go +++ b/pkg/server/runner.go @@ -73,6 +73,7 @@ func Run(ctx context.Context, cfg *Config) error { cfg.OrgConfigPath, cfg.Ref, sourceSystem, + cfg.EnterpriseConfigOrgs, ) if err != nil { return fmt.Errorf("failed to create config evaluator: %w", err) @@ -85,7 +86,7 @@ func Run(ctx context.Context, cfg *Config) error { jwkResolver := NewOIDCResolver(ctx, cfg.IssuerAllowlist, cfg.JWKSCacheDuration) // Create the Router for the token minting server. - tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}, cfg.EnforceReadOnly, cfg.EnterpriseConfigOrgs) + tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}, cfg.EnforceReadOnly) if err != nil { return fmt.Errorf("failed to start token mint server: %w", err) } diff --git a/pkg/server/source/github.go b/pkg/server/source/github.go index 02ebe66..dd6ab92 100644 --- a/pkg/server/source/github.go +++ b/pkg/server/source/github.go @@ -17,11 +17,11 @@ package source import ( "context" "crypto" - "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -156,36 +156,30 @@ func (g *gitHubSourceSystem) installationForEnterprise(ctx context.Context, app return nil, fmt.Errorf("failed to generate app JWT: %w", err) } - baseURL := g.baseURL - if baseURL == "" { - baseURL = "https://api.github.com" + client := github.NewClient(g.httpClient).WithAuthToken(jwtToken) + if g.baseURL != "" { + parsedURL, err := url.Parse(g.baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse base URL: %w", err) + } + if !strings.HasSuffix(parsedURL.Path, "/") { + parsedURL.Path += "/" + } + client.BaseURL = parsedURL } - url := strings.TrimSuffix(baseURL, "/") + "/enterprises/" + enterprise + "/installation" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + + req, err := client.NewRequest(http.MethodGet, fmt.Sprintf("enterprises/%s/installation", enterprise), nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+jwtToken) - req.Header.Set("Accept", "application/vnd.github+json") - resp, err := g.httpClient.Do(req) + var result github.Installation + _, err = client.Do(ctx, req, &result) if err != nil { return nil, fmt.Errorf("failed to get enterprise installation: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d getting enterprise installation", resp.StatusCode) - } - - var result struct { - ID int64 `json:"id"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode enterprise installation response: %w", err) - } - return app.InstallationForID(ctx, fmt.Sprint(result.ID)) + return app.InstallationForID(ctx, fmt.Sprint(result.GetID())) } // MintEnterpriseAccessToken implements SourceSystem. diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index b12f0b6..c82f139 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -42,11 +42,10 @@ const ( // TokenMinterServer is the implementation of an HTTP server that exchanges // a GitHub OIDC token for a GitHub application token with eleveated privlidges. type TokenMinterServer struct { - sourceSystem source.System - configStore config.ConfigEvaluator - parser *JWTParser - enforceReadOnly bool - enterpriseConfigOrgs []string + sourceSystem source.System + configStore config.ConfigEvaluator + parser *JWTParser + enforceReadOnly bool } // TokenRequest is a struct that contains the list of repositories and the @@ -60,8 +59,7 @@ type TokenRequest struct { Scope string `json:"scope"` } -// apiResponse is a structure that contains a http status code, -// a string response message and any error that might have occurred +// apiResponse represents a HTTP status code and message to be returned // in the processing of a request. type apiResponse struct { Code int @@ -71,13 +69,12 @@ type apiResponse struct { // NewRouter creates a new HTTP server implementation that will exchange // a GitHub OIDC token for a GitHub application token with eleveated privlidges. -func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser, enforceReadOnly bool, enterpriseConfigOrgs []string) (*TokenMinterServer, error) { +func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser, enforceReadOnly bool) (*TokenMinterServer, error) { return &TokenMinterServer{ - sourceSystem: sourceSystem, - configStore: configStore, - parser: parser, - enforceReadOnly: enforceReadOnly, - enterpriseConfigOrgs: enterpriseConfigOrgs, + sourceSystem: sourceSystem, + configStore: configStore, + parser: parser, + enforceReadOnly: enforceReadOnly, }, nil } @@ -168,33 +165,21 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { errors.New("request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed"), } } - if len(s.enterpriseConfigOrgs) == 0 { - return &apiResponse{ - http.StatusForbidden, - "enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes", - errors.New("enterprise token minting is not enabled: no organizations are authorized to configure enterprise scopes"), - } - } - - var scope *config.Scope - var source string - var err error - for _, org := range s.enterpriseConfigOrgs { - scope, source, err = s.configStore.Eval(ctx, org, claims.ParsedRepoName, request.Scope, claims.asMap()) - if err == nil && scope != nil { - break - } - } + scope, _, err := s.configStore.EvalEnterprise(ctx, request.Scope, claims.asMap()) if err != nil { + if strings.Contains(err.Error(), "not enabled") { + return &apiResponse{ + http.StatusForbidden, + err.Error(), + err, + } + } return &apiResponse{ - http.StatusInternalServerError, + http.StatusForbidden, fmt.Sprintf("requested scope %q is not found", request.Scope), - fmt.Errorf("error reading configuration from configuration store with config_source %s: %w", source, err), + fmt.Errorf("error evaluating enterprise scope: %w", err), } } - if scope == nil { - return &apiResponse{http.StatusForbidden, fmt.Sprintf("no permissions available for scope %q", request.Scope), nil} - } if len(request.Permissions) == 0 { if _, ok := scope.Permissions["*"]; ok { request.Permissions = nil diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 3697afe..87da683 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -580,6 +580,50 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { expResp: "request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed", expErr: "request for 'enterprise' also contained request for 'org_name' or 'repositories' and that is not allowed", }, + { + name: "unhappy_path_enterprise_token_no_matching_scope", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"non_existent_scope", "enterprise":"alphabet"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + enterpriseConfigOrgs: []string{"org1"}, + expCode: 403, + expResp: "requested scope \"non_existent_scope\" is not found", + expErr: "no permissions available for enterprise scope \"non_existent_scope\"", + }, + { + name: "unhappy_path_enterprise_token_org_not_allowlisted", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"minty_error_test", "enterprise":"alphabet"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + enterpriseConfigOrgs: []string{"unauthorized_org"}, + expCode: 403, + expResp: "requested scope \"minty_error_test\" is not found", + expErr: "no permissions available for enterprise scope \"minty_error_test\"", + }, { name: "unhappy_path_mixing_all_and_specific_repos", req: func() *http.Request { @@ -664,12 +708,12 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { t.Fatal(err) } - configStore, err := config.NewConfigEvaluator(1*time.Hour, "../../testdata/configs", ".github/minty.yaml", ".minty", "minty.yaml", "main", sourceSystem) + configStore, err := config.NewConfigEvaluator(1*time.Hour, "../../testdata/configs", ".github/minty.yaml", ".minty", "minty.yaml", "main", sourceSystem, tc.enterpriseConfigOrgs) if err != nil { t.Fatal(err) } - server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}, tc.enforceReadOnly, tc.enterpriseConfigOrgs) + server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}, tc.enforceReadOnly) if err != nil { t.Fatal(err) } @@ -1397,3 +1441,11 @@ func (m *mockConfigEvaluator) Eval(ctx context.Context, org, repo, scope string, } return s, m.mockedFile, m.mockedError } + +func (m *mockConfigEvaluator) EvalEnterprise(ctx context.Context, scope string, token interface{}) (*config.Scope, string, error) { + s, ok := m.mockedScopes["enterprise"] + if !ok { + return nil, "", fmt.Errorf("unexpected scope: %s", scope) + } + return s, m.mockedFile, m.mockedError +} From 244c21ae87282bf288b6834a312b36cf937bfa1d Mon Sep 17 00:00:00 2001 From: pdewilde Date: Wed, 29 Jul 2026 17:02:34 -0700 Subject: [PATCH 3/5] test(config): add unit tests for EvalEnterprise and update README documentation --- README.md | 2 + pkg/config/config_evaluator_test.go | 71 +++++++++++++++++++++++++++++ pkg/server/token_minter.go | 30 ++++++++---- pkg/server/token_minter_test.go | 2 +- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 43d02b1..b881b6d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ GITHUB_REQUEST_MULTIPLIER | The backoff multiplier for GitHub API requests. GITHUB_REQUEST_RETRY_404 | Whether to retry GitHub API requests that return a 404 Not Found status. Defaults to true. GITHUB_REQUEST_RETRY_422 | Whether to retry GitHub API requests that return a 422 Unprocessable Entity status. Defaults to true. ENFORCE_READ_ONLY | Whether to enforce read-only permissions for all minted tokens. Defaults to false. +ENTERPRISE_CONFIG_ORGS | Comma-separated list of organization names authorized to configure enterprise scopes. Defaults to empty (disabled). ### CLI Usage @@ -127,6 +128,7 @@ This command starts the GitHub Token Minter server. | `--github-request-retry-404` | `GITHUB_REQUEST_RETRY_404` | Whether to retry GitHub API requests that return a 404 Not Found status. Defaults to true. | | `--github-request-retry-422` | `GITHUB_REQUEST_RETRY_422` | Whether to retry GitHub API requests that return a 422 Unprocessable Entity status. Defaults to true. | | `--enforce-read-only` | `ENFORCE_READ_ONLY` | Whether to enforce read-only permissions for all minted tokens. Defaults to false. | +| `--enterprise-config-orgs` | `ENTERPRISE_CONFIG_ORGS` | Comma-separated list of organization names authorized to configure enterprise scopes. Defaults to empty (disabled). | #### `minty tools validate-cfg` diff --git a/pkg/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index d45294c..b426047 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -388,3 +388,74 @@ func TestOrderedConfigFileLoader(t *testing.T) { }) } } + +func TestConfigEvaluator_EvalEnterprise(t *testing.T) { + t.Parallel() + + env, err := cel.NewEnv(cel.Variable(AssertionKey, cel.DynType)) + if err != nil { + t.Fatal(err) + } + + org1Loader := &compilingConfigLoader{ + env: env, + loader: &testConfigFileLoader{ + result: &Config{ + Scopes: map[string]*Scope{ + "ent_scope": {Rule: &Rule{If: "assertion.target == 'org1_val'"}}, + }, + }, + }, + } + org2Loader := &compilingConfigLoader{ + env: env, + loader: &testConfigFileLoader{ + result: &Config{ + Scopes: map[string]*Scope{ + "ent_scope": {Rule: &Rule{If: "assertion.target == 'org2_val'"}}, + }, + }, + }, + } + + evaluator := &configEvaluator{ + loaders: []ConfigFileLoader{ + org1Loader, + org2Loader, + }, + enterpriseConfigOrgs: []string{"org1", "org2"}, + } + + ctx := t.Context() + + t.Run("disabled_enterprise_minting", func(t *testing.T) { + t.Parallel() + disabledEval := &configEvaluator{loaders: []ConfigFileLoader{org1Loader}} + _, _, err := disabledEval.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "org1_val"}) + if diff := testutil.DiffErrString(err, "enterprise token minting is not enabled"); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("fallback_to_second_allowlisted_org", func(t *testing.T) { + t.Parallel() + scope, source, err := evaluator.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "org2_val"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scope == nil { + t.Fatalf("expected scope, got nil") + } + if source == "" { + t.Errorf("expected non-empty source") + } + }) + + t.Run("all_allowlisted_orgs_fail", func(t *testing.T) { + t.Parallel() + _, _, err := evaluator.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "no_match"}) + if err == nil { + t.Fatalf("expected error when no allowlisted org matches, got nil") + } + }) +} diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index c82f139..48999a2 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -59,7 +59,8 @@ type TokenRequest struct { Scope string `json:"scope"` } -// apiResponse represents a HTTP status code and message to be returned +// apiResponse is a structure that contains a http status code, +// a string response message and any error that might have occurred // in the processing of a request. type apiResponse struct { Code int @@ -191,10 +192,7 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { return &apiResponse{http.StatusForbidden, "requested permissions are not authorized for this scope", err} } if s.enforceReadOnly { - request.Permissions = map[string]string{ - "contents": "read", - "metadata": "read", - } + request.Permissions = enforceReadOnlyPermissions(request.Permissions) } logger.InfoContext(ctx, "generating enterprise token", "claims", claims, @@ -218,14 +216,12 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { // If the server is configured to enforce read-only permissions, then // overwrite the requested permissions with a read-only set. if s.enforceReadOnly { + newPerms := enforceReadOnlyPermissions(request.Permissions) logger.DebugContext(ctx, "enforcing read-only permissions", "original_permissions", request.Permissions, - "new_permissions", map[string]string{"contents": "read", "metadata": "read"}, + "new_permissions", newPerms, ) - request.Permissions = map[string]string{ - "contents": "read", - "metadata": "read", - } + request.Permissions = newPerms } // If no repositories are requested, default to the repository from the @@ -428,3 +424,17 @@ func matchesAllowed(allow, request string) bool { return false } } + +func enforceReadOnlyPermissions(permissions map[string]string) map[string]string { + if len(permissions) == 0 { + return map[string]string{ + "contents": "read", + "metadata": "read", + } + } + readOnly := make(map[string]string, len(permissions)) + for k := range permissions { + readOnly[k] = "read" + } + return readOnly +} diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 87da683..4e7ee09 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -685,7 +685,7 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { }, enforceReadOnly: true, expCode: 200, - expResp: "[contents=read metadata=read]", + expResp: "[contents=read issues=read]", }, } From 8519760ab3e0830812ab9477e81bd2ad9382288c Mon Sep 17 00:00:00 2001 From: pdewilde Date: Thu, 30 Jul 2026 09:51:30 -0700 Subject: [PATCH 4/5] refactor(server): reject enterprise tokens when read-only mode is enabled and use table-driven tests for EvalEnterprise --- pkg/config/config_evaluator_test.go | 84 +++++++++++++++++++---------- pkg/server/token_minter.go | 28 ++++------ pkg/server/token_minter_test.go | 25 ++++++++- 3 files changed, 89 insertions(+), 48 deletions(-) diff --git a/pkg/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index b426047..bb4d99d 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -426,36 +426,62 @@ func TestConfigEvaluator_EvalEnterprise(t *testing.T) { enterpriseConfigOrgs: []string{"org1", "org2"}, } - ctx := t.Context() + disabledEvaluator := &configEvaluator{ + loaders: []ConfigFileLoader{ + org1Loader, + }, + } - t.Run("disabled_enterprise_minting", func(t *testing.T) { - t.Parallel() - disabledEval := &configEvaluator{loaders: []ConfigFileLoader{org1Loader}} - _, _, err := disabledEval.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "org1_val"}) - if diff := testutil.DiffErrString(err, "enterprise token minting is not enabled"); diff != "" { - t.Fatal(diff) - } - }) + cases := []struct { + name string + reader ConfigEvaluator + scope string + token interface{} + want *Scope + expErrMsg string + }{ + { + name: "disabled_enterprise_minting", + reader: disabledEvaluator, + scope: "ent_scope", + token: map[string]string{"target": "org1_val"}, + want: nil, + expErrMsg: "enterprise token minting is not enabled", + }, + { + name: "fallback_to_second_allowlisted_org", + reader: evaluator, + scope: "ent_scope", + token: map[string]string{"target": "org2_val"}, + want: &Scope{ + Rule: &Rule{If: "assertion.target == 'org2_val'"}, + }, + expErrMsg: "", + }, + { + name: "all_allowlisted_orgs_fail", + reader: evaluator, + scope: "ent_scope", + token: map[string]string{"target": "no_match"}, + want: nil, + expErrMsg: "no permissions available for enterprise scope", + }, + } - t.Run("fallback_to_second_allowlisted_org", func(t *testing.T) { - t.Parallel() - scope, source, err := evaluator.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "org2_val"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if scope == nil { - t.Fatalf("expected scope, got nil") - } - if source == "" { - t.Errorf("expected non-empty source") - } - }) + ctx := t.Context() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - t.Run("all_allowlisted_orgs_fail", func(t *testing.T) { - t.Parallel() - _, _, err := evaluator.EvalEnterprise(ctx, "ent_scope", map[string]string{"target": "no_match"}) - if err == nil { - t.Fatalf("expected error when no allowlisted org matches, got nil") - } - }) + got, _, err := tc.reader.EvalEnterprise(ctx, tc.scope, tc.token) + if diff := cmp.Diff(tc.want, got, cmp.FilterPath(func(p cmp.Path) bool { + return p.Last().String() != "Program" + }, cmp.Ignore())); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + if msg := testutil.DiffErrString(err, tc.expErrMsg); msg != "" { + t.Fatal(msg) + } + }) + } } diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index 48999a2..821af6a 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -192,7 +192,11 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { return &apiResponse{http.StatusForbidden, "requested permissions are not authorized for this scope", err} } if s.enforceReadOnly { - request.Permissions = enforceReadOnlyPermissions(request.Permissions) + return &apiResponse{ + http.StatusForbidden, + "read-only mode is not supported for enterprise scoped tokens", + errors.New("read-only mode is not supported for enterprise scoped tokens"), + } } logger.InfoContext(ctx, "generating enterprise token", "claims", claims, @@ -216,12 +220,14 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { // If the server is configured to enforce read-only permissions, then // overwrite the requested permissions with a read-only set. if s.enforceReadOnly { - newPerms := enforceReadOnlyPermissions(request.Permissions) logger.DebugContext(ctx, "enforcing read-only permissions", "original_permissions", request.Permissions, - "new_permissions", newPerms, + "new_permissions", map[string]string{"contents": "read", "metadata": "read"}, ) - request.Permissions = newPerms + request.Permissions = map[string]string{ + "contents": "read", + "metadata": "read", + } } // If no repositories are requested, default to the repository from the @@ -424,17 +430,3 @@ func matchesAllowed(allow, request string) bool { return false } } - -func enforceReadOnlyPermissions(permissions map[string]string) map[string]string { - if len(permissions) == 0 { - return map[string]string{ - "contents": "read", - "metadata": "read", - } - } - readOnly := make(map[string]string, len(permissions)) - for k := range permissions { - readOnly[k] = "read" - } - return readOnly -} diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 4e7ee09..70c7343 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -624,6 +624,29 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { expResp: "requested scope \"minty_error_test\" is not found", expErr: "no permissions available for enterprise scope \"minty_error_test\"", }, + { + name: "unhappy_path_enterprise_token_read_only", + req: func() *http.Request { + body := strings.NewReader(`{"scope":"minty_error_test", "enterprise":"alphabet"}`) + r := httptest.NewRequest("GET", "/", body).WithContext(ctx) + + signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { + b.Issuer(config.GitHubIssuer) + b.Claim("repository", "abcxyz/pkg") + b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") + }) + r.Header.Set("X-OIDC-Token", signed) + return r + }(), + resolver: mockJwksResolver{ + keySet: jwkCachedSet, + }, + enterpriseConfigOrgs: []string{"org1"}, + enforceReadOnly: true, + expCode: 403, + expResp: "read-only mode is not supported for enterprise scoped tokens", + expErr: "read-only mode is not supported for enterprise scoped tokens", + }, { name: "unhappy_path_mixing_all_and_specific_repos", req: func() *http.Request { @@ -685,7 +708,7 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { }, enforceReadOnly: true, expCode: 200, - expResp: "[contents=read issues=read]", + expResp: "[contents=read metadata=read]", }, } From f800a3e02261cbeb18f4a6542fc08b450eae3e27 Mon Sep 17 00:00:00 2001 From: pdewilde Date: Thu, 30 Jul 2026 09:53:27 -0700 Subject: [PATCH 5/5] fix(server): return 400 Bad Request for read-only enterprise token requests --- pkg/config/config_evaluator_test.go | 2 +- pkg/server/token_minter.go | 2 +- pkg/server/token_minter_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index bb4d99d..7039d58 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -468,10 +468,10 @@ func TestConfigEvaluator_EvalEnterprise(t *testing.T) { }, } - ctx := t.Context() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() + ctx := t.Context() got, _, err := tc.reader.EvalEnterprise(ctx, tc.scope, tc.token) if diff := cmp.Diff(tc.want, got, cmp.FilterPath(func(p cmp.Path) bool { diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index 821af6a..320f551 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -193,7 +193,7 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { } if s.enforceReadOnly { return &apiResponse{ - http.StatusForbidden, + http.StatusBadRequest, "read-only mode is not supported for enterprise scoped tokens", errors.New("read-only mode is not supported for enterprise scoped tokens"), } diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 70c7343..fd8df8d 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -643,7 +643,7 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { }, enterpriseConfigOrgs: []string{"org1"}, enforceReadOnly: true, - expCode: 403, + expCode: 400, expResp: "read-only mode is not supported for enterprise scoped tokens", expErr: "read-only mode is not supported for enterprise scoped tokens", },