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.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/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index d45294c..7039d58 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -388,3 +388,100 @@ 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"}, + } + + disabledEvaluator := &configEvaluator{ + loaders: []ConfigFileLoader{ + org1Loader, + }, + } + + 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", + }, + } + + 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 { + 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/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..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) diff --git a/pkg/server/source/github.go b/pkg/server/source/github.go index 323bdf3..dd6ab92 100644 --- a/pkg/server/source/github.go +++ b/pkg/server/source/github.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -149,6 +150,74 @@ 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) + } + + 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 + } + + 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) + } + + var result github.Installation + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, fmt.Errorf("failed to get enterprise installation: %w", err) + } + + return app.InstallationForID(ctx, fmt.Sprint(result.GetID())) +} + +// 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..320f551 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -53,6 +53,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"` @@ -156,6 +157,59 @@ 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"), + } + } + 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.StatusForbidden, + fmt.Sprintf("requested scope %q is not found", request.Scope), + fmt.Errorf("error evaluating enterprise scope: %w", err), + } + } + 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 { + return &apiResponse{ + http.StatusBadRequest, + "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, + "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..fd8df8d 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,137 @@ 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_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_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: 400, + 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 { @@ -593,7 +731,7 @@ 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) } @@ -1326,3 +1464,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 +}