diff --git a/api/repo/create.go b/api/repo/create.go index 277249e11..9fe69dfad 100644 --- a/api/repo/create.go +++ b/api/repo/create.go @@ -75,6 +75,7 @@ func CreateRepo(c *gin.Context) { maxBuildLimit := c.Value("maxBuildLimit").(int64) defaultRepoEvents := c.Value("defaultRepoEvents").([]string) defaultRepoEventsMask := c.Value("defaultRepoEventsMask").(int64) + defaultRepoApproveBuild := c.Value("defaultRepoApproveBuild").(string) ctx := c.Request.Context() @@ -149,9 +150,21 @@ func CreateRepo(c *gin.Context) { // set the fork policy field based off the input provided if len(input.GetApproveBuild()) > 0 { + // ensure the approve build setting matches one of the expected values + if input.GetApproveBuild() != constants.ApproveForkAlways && + input.GetApproveBuild() != constants.ApproveForkNoWrite && + input.GetApproveBuild() != constants.ApproveNever && + input.GetApproveBuild() != constants.ApproveOnce { + retErr := fmt.Errorf("approve_build of %s is invalid", input.GetApproveBuild()) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + r.SetApproveBuild(input.GetApproveBuild()) } else { - r.SetApproveBuild(constants.ApproveForkAlways) + r.SetApproveBuild(defaultRepoApproveBuild) } // fields restricted to platform admins diff --git a/api/repo/update.go b/api/repo/update.go index 298a874cd..ba21996d3 100644 --- a/api/repo/update.go +++ b/api/repo/update.go @@ -158,6 +158,18 @@ func UpdateRepo(c *gin.Context) { } if len(input.GetApproveBuild()) > 0 { + // ensure the approve build setting matches one of the expected values + if input.GetApproveBuild() != constants.ApproveForkAlways && + input.GetApproveBuild() != constants.ApproveForkNoWrite && + input.GetApproveBuild() != constants.ApproveNever && + input.GetApproveBuild() != constants.ApproveOnce { + retErr := fmt.Errorf("approve_build of %s is invalid", input.GetApproveBuild()) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + // update fork policy if set r.SetApproveBuild(input.GetApproveBuild()) } diff --git a/api/webhook/post.go b/api/webhook/post.go index 39bf93430..935f5dd6c 100644 --- a/api/webhook/post.go +++ b/api/webhook/post.go @@ -758,6 +758,26 @@ func PostWebhook(c *gin.Context) { return } + fallthrough + case constants.ApproveOnce: + // determine if build sender is in the contributors list for the repo + // + // NOTE: this call is cumbersome for repos with lots of contributors. Potential TODO: improve this if + // GitHub adds a single-contributor API endpoint. + contributor, err := scm.FromContext(c).RepoContributor(ctx, u, b.GetSender(), r.GetOrg(), r.GetName()) + if err != nil { + util.HandleError(c, http.StatusInternalServerError, err) + } + + if !contributor { + err = gatekeepBuild(c, b, repo, u) + if err != nil { + util.HandleError(c, http.StatusInternalServerError, err) + } + + return + } + fallthrough case constants.ApproveNever: fallthrough diff --git a/cmd/vela-server/main.go b/cmd/vela-server/main.go index 67f8a394e..c2875409e 100644 --- a/cmd/vela-server/main.go +++ b/cmd/vela-server/main.go @@ -137,6 +137,12 @@ func main() { Name: "default-repo-events-mask", Usage: "set default event mask for newly activated repositories", }, + &cli.StringFlag{ + EnvVars: []string{"VELA_DEFAULT_REPO_APPROVE_BUILD"}, + Name: "default-repo-approve-build", + Usage: "override default approve build for newly activated repositories", + Value: constants.ApproveForkAlways, + }, // Token Manager Flags &cli.DurationFlag{ EnvVars: []string{"VELA_USER_ACCESS_TOKEN_DURATION", "USER_ACCESS_TOKEN_DURATION"}, diff --git a/cmd/vela-server/server.go b/cmd/vela-server/server.go index 659c4838b..1910c2d70 100644 --- a/cmd/vela-server/server.go +++ b/cmd/vela-server/server.go @@ -120,6 +120,7 @@ func server(c *cli.Context) error { middleware.Worker(c.Duration("worker-active-interval")), middleware.DefaultRepoEvents(c.StringSlice("default-repo-events")), middleware.DefaultRepoEventsMask(c.Int64("default-repo-events-mask")), + middleware.DefaultRepoApproveBuild(c.String("default-repo-approve-build")), middleware.AllowlistSchedule(c.StringSlice("vela-schedule-allowlist")), middleware.ScheduleFrequency(c.Duration("schedule-minimum-frequency")), ) diff --git a/cmd/vela-server/validate.go b/cmd/vela-server/validate.go index b05e012c6..26b8eda89 100644 --- a/cmd/vela-server/validate.go +++ b/cmd/vela-server/validate.go @@ -98,6 +98,13 @@ func validateCore(c *cli.Context) error { } } + if c.String("default-repo-approve-build") != constants.ApproveForkAlways && + c.String("default-repo-approve-build") != constants.ApproveNever && + c.String("default-repo-approve-build") != constants.ApproveForkNoWrite && + c.String("default-repo-approve-build") != constants.ApproveOnce { + return fmt.Errorf("default-repo-approve-build (VELA_DEFAULT_REPO_APPROVE_BUILD) has the unsupported value of %s", c.String("default-repo-approve-build")) + } + return nil } diff --git a/router/middleware/default_repo_events.go b/router/middleware/default_repo_settings.go similarity index 67% rename from router/middleware/default_repo_events.go rename to router/middleware/default_repo_settings.go index a89a682c1..c3799c6df 100644 --- a/router/middleware/default_repo_events.go +++ b/router/middleware/default_repo_settings.go @@ -23,3 +23,12 @@ func DefaultRepoEventsMask(defaultRepoEventsMask int64) gin.HandlerFunc { c.Next() } } + +// DefaultRepoApproveBuild is a middleware function that attaches the defaultRepoApproveBuild +// to enable the server to override the default repo approve build setting. +func DefaultRepoApproveBuild(defaultRepoApproveBuild string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("defaultRepoApproveBuild", defaultRepoApproveBuild) + c.Next() + } +} diff --git a/router/middleware/default_repo_events_test.go b/router/middleware/default_repo_settings_test.go similarity index 68% rename from router/middleware/default_repo_events_test.go rename to router/middleware/default_repo_settings_test.go index 8ec327f3f..1e5a1b744 100644 --- a/router/middleware/default_repo_events_test.go +++ b/router/middleware/default_repo_settings_test.go @@ -78,3 +78,36 @@ func TestMiddleware_DefaultRepoEventsMask(t *testing.T) { t.Errorf("DefaultRepoEventsMask is %v, want %v", got, want) } } + +func TestMiddleware_DefaultRepoApproveBuild(t *testing.T) { + // setup types + var got string + + want := "fork-no-write" + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/health", nil) + + // setup mock server + engine.Use(DefaultRepoApproveBuild(want)) + engine.GET("/health", func(c *gin.Context) { + got = c.Value("defaultRepoApproveBuild").(string) + + c.Status(http.StatusOK) + }) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("DefaultRepoApproveBuild returned %v, want %v", resp.Code, http.StatusOK) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("DefaultRepoApproveBuild is %v, want %v", got, want) + } +} diff --git a/scm/github/access.go b/scm/github/access.go index 62822f6c3..ea2445a3b 100644 --- a/scm/github/access.go +++ b/scm/github/access.go @@ -185,3 +185,48 @@ func (c *client) ListUsersTeamsForOrg(ctx context.Context, u *library.User, org return userTeams, nil } + +// RepoContributor lists all contributors from a repository and checks if the sender is one of the contributors. +func (c *client) RepoContributor(ctx context.Context, owner *library.User, sender, org, repo string) (bool, error) { + c.Logger.WithFields(logrus.Fields{ + "org": org, + "repo": repo, + "user": sender, + }).Tracef("capturing %s contributor status for repo %s/%s", sender, org, repo) + + // create GitHub OAuth client with repo owner's token + client := c.newClientToken(owner.GetToken()) + + // set the max per page for the options to capture the list of repos + opts := github.ListContributorsOptions{ + ListOptions: github.ListOptions{ + PerPage: 100, // 100 is max + }, + } + + for { + // send API call to list all contributors for repository + contributors, resp, err := client.Repositories.ListContributors(ctx, org, repo, &opts) + if err != nil { + return false, err + } + + // match login to sender to see if they are a contributor + // + // check this as we page through the results to spare API + for _, contributor := range contributors { + if strings.EqualFold(contributor.GetLogin(), sender) { + return true, nil + } + } + + // break the loop if there is no more results to page through + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + return false, nil +} diff --git a/scm/github/access_test.go b/scm/github/access_test.go index d4d0044d3..77ba7e347 100644 --- a/scm/github/access_test.go +++ b/scm/github/access_test.go @@ -411,3 +411,80 @@ func TestGithub_TeamList(t *testing.T) { t.Errorf("TeamAccess is %v, want %v", got, want) } } + +func TestGithub_RepoContributor(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.GET("/api/v3/repos/:org/:repo/contributors", func(c *gin.Context) { + if c.Param("org") != "github" { + c.Status(http.StatusNotFound) + + return + } + + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/list_contributors.json") + }) + + s := httptest.NewServer(engine) + defer s.Close() + + // setup types + u := new(library.User) + u.SetName("foo") + u.SetToken("bar") + + tests := []struct { + name string + sender string + org string + repo string + want bool + wantErr bool + }{ + { + name: "repo contributor", + sender: "octocat", + org: "github", + repo: "example", + want: true, + }, + { + name: "repo non-contributor", + sender: "userA", + org: "github", + repo: "example", + want: false, + }, + { + name: "repo not found", + sender: "octocat", + org: "foo", + repo: "example", + wantErr: true, + }, + } + + client, _ := NewTest(s.URL) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := client.RepoContributor(context.TODO(), u, tt.sender, tt.org, tt.repo) + + if (err != nil) != tt.wantErr { + t.Errorf("RepoContributor() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("RepoContributor() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/scm/github/testdata/list_contributors.json b/scm/github/testdata/list_contributors.json new file mode 100644 index 000000000..0ee73857f --- /dev/null +++ b/scm/github/testdata/list_contributors.json @@ -0,0 +1,44 @@ +[ + { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false, + "contributions": 32 + }, + { + "login": "octokitty", + "id": 2, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false, + "contributions": 32 + } +] diff --git a/scm/service.go b/scm/service.go index c7b459e64..074495cee 100644 --- a/scm/service.go +++ b/scm/service.go @@ -51,6 +51,9 @@ type Service interface { // TeamAccess defines a function that captures // the user's access level for a team. TeamAccess(context.Context, *library.User, string, string) (string, error) + // RepoContributor defines a function that captures + // whether the user is a contributor for a repo. + RepoContributor(context.Context, *library.User, string, string, string) (bool, error) // Teams SCM Interface Functions