From b8601720aab464721cf1fa3fbfea21ad5f2397fc Mon Sep 17 00:00:00 2001 From: aknysh Date: Fri, 12 Oct 2018 12:50:47 -0400 Subject: [PATCH 1/4] Add `gh-team-whitelist` command line argument --- .gitignore | 3 +- .travis.yml | 42 ++++++++++ Makefile | 6 ++ cmd/server.go | 14 ++++ helm/atlantis/Chart.yaml | 2 +- main.go | 2 +- server/events/team_whitelist_checker.go | 83 +++++++++++++++++++ server/events/vcs/bitbucketcloud/client.go | 5 ++ server/events/vcs/bitbucketserver/client.go | 5 ++ server/events/vcs/client.go | 1 + server/events/vcs/github_client.go | 27 ++++++ server/events/vcs/gitlab_client.go | 5 ++ .../events/vcs/not_configured_vcs_client.go | 3 + server/events/vcs/proxy.go | 5 ++ server/events_controller.go | 37 +++++++++ server/server.go | 6 ++ 16 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 .travis.yml create mode 100644 server/events/team_whitelist_checker.go diff --git a/.gitignore b/.gitignore index 14349d26b5..27fbd3ddc2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ output .terraform/ node_modules/ **/.vuepress/dist -helm/test-values.yaml \ No newline at end of file +helm/test-values.yaml +dist/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..7c0254d47f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,42 @@ +sudo: required +language: go +go: +- 1.11.x +addons: + apt: + packages: + - git + - make + - curl + +env: +- DOCKER_IMAGE_NAME=cloudposse/atlantis + +services: +- docker + +install: +- make init +- make travis:docker-login +- make go:deps-build +- make go:deps-dev + +script: +- make go:deps +- make go:test +- make go:lint +- make go:build-all +- ls -l release/ +- make docker:build + +after_success: +- make travis:docker-tag-and-push + +deploy: +- provider: releases + api_key: "$GITHUB_API_KEY" + file_glob: true + file: "release/*" + skip_cleanup: true + on: + tags: true diff --git a/Makefile b/Makefile index 5884cfe355..f248845422 100644 --- a/Makefile +++ b/Makefile @@ -86,3 +86,9 @@ end-to-end-tests: ## Run e2e tests website-dev: yarn website:dev + +go-get: + go get + +go-build: + CGO_ENABLED=0 go build -v -o "./dist/bin/atlantis" *.go diff --git a/cmd/server.go b/cmd/server.go index e983d5de2e..0cf11c8285 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -44,6 +44,7 @@ const ( ConfigFlag = "config" DataDirFlag = "data-dir" GHHostnameFlag = "gh-hostname" + GHTeamWhitelistFlag = "gh-team-whitelist" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec @@ -62,6 +63,7 @@ const ( DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" DefaultGHHostname = "github.com" + DefaultGHTeamWhitelist = "*:*" DefaultGitlabHostname = "gitlab.com" DefaultLogLevel = "info" DefaultPort = 4141 @@ -111,6 +113,15 @@ var stringFlags = []stringFlag{ description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.", defaultValue: DefaultGHHostname, }, + { + name: GHTeamWhitelistFlag, + description: "Comma separated list of key-value pairs representing the GitHub teams and the operations that the members of a particular team is allowed to perform. " + + "The format is {team}:{command},{team}:{command}, ex. dev:plan,ops:apply,devops:*. " + + "This example means to give the users from the 'dev' GitHub team the permissions to execute the 'plan' command, give the `ops` team the permissions to execute the 'apply' command, " + + "and allow the `devops` team to perform any operation. If this argument is not provided, the default value (*:*) will be used and the default behavior will be to not check permissions " + + "and to allow users from any team to perform any operation.", + defaultValue: DefaultGHTeamWhitelist, + }, { name: GHUserFlag, description: "GitHub username of API user.", @@ -365,6 +376,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.Port == 0 { c.Port = DefaultPort } + if c.GithubTeamWhitelist == "" { + c.GithubTeamWhitelist = DefaultGHTeamWhitelist + } } func (s *ServerCmd) validate(userConfig server.UserConfig) error { diff --git a/helm/atlantis/Chart.yaml b/helm/atlantis/Chart.yaml index 18c94fdd2a..c3427f3843 100644 --- a/helm/atlantis/Chart.yaml +++ b/helm/atlantis/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v1 -appVersion: "v0.4.5" +appVersion: "v0.5.0" description: A Helm chart for Atlantis https://www.runatlantis.io name: atlantis version: 0.1.0 diff --git a/main.go b/main.go index 07458c10b1..396d65b9ec 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ import ( "github.com/spf13/viper" ) -const atlantisVersion = "0.4.10" +const atlantisVersion = "0.5.0" func main() { v := viper.New() diff --git a/server/events/team_whitelist_checker.go b/server/events/team_whitelist_checker.go new file mode 100644 index 0000000000..ddabc046e9 --- /dev/null +++ b/server/events/team_whitelist_checker.go @@ -0,0 +1,83 @@ +// Copyright 2017 HootSuite Media Inc. +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Modified hereafter by contributors to runatlantis/atlantis. + +package events + +import ( + "strings" +) + +// Wildcard matches all teams and all commands +const wildcard = "*" + +// mapOfStrings is an alias for map[string]string +type mapOfStrings map[string]string + +// TeamWhitelistChecker implements checking the teams and the operations that the members +// of a particular team are allowed to perform +type TeamWhitelistChecker struct { + rules []mapOfStrings +} + +// NewTeamWhitelistChecker constructs a new checker +func NewTeamWhitelistChecker(whitelist string) (*TeamWhitelistChecker, error) { + var rules []mapOfStrings + pairs := strings.Split(whitelist, ",") + for _, pair := range pairs { + values := strings.Split(pair, ":") + team := strings.TrimSpace(values[0]) + command := strings.TrimSpace(values[1]) + m := mapOfStrings{team: command} + rules = append(rules, m) + } + return &TeamWhitelistChecker{ + rules: rules, + }, nil +} + +// IsCommandAllowedForTeam returns true if the team is allowed to execute the command +// and false otherwise. +func (checker *TeamWhitelistChecker) IsCommandAllowedForTeam(team string, command string) bool { + t := strings.TrimSpace(team) + c := strings.TrimSpace(command) + for _, rule := range checker.rules { + for key, value := range rule { + if (key == wildcard || strings.EqualFold(key, t)) && (value == wildcard || strings.EqualFold(value, c)) { + return true + } + } + } + return false +} + +// IsCommandAllowedForAnyTeam returns true if any of the teams is allowed to execute the command +// and false otherwise. +func (checker *TeamWhitelistChecker) IsCommandAllowedForAnyTeam(teams []string, command string) bool { + c := strings.TrimSpace(command) + if teams == nil || len(teams) == 0 { + for _, rule := range checker.rules { + for key, value := range rule { + if (key == wildcard) && (value == wildcard || strings.EqualFold(value, c)) { + return true + } + } + } + } else { + for _, t := range teams { + if checker.IsCommandAllowedForTeam(t, command) { + return true + } + } + } + return false +} diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index 81272f1f11..ca8fa6c5b5 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -181,3 +181,8 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } return respBody, nil } + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (g *Client) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, nil +} diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index d9a3668f01..b57454ec0b 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -220,3 +220,8 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } return respBody, nil } + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (g *Client) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, nil +} diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index 583206bb95..24983ace1f 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -25,4 +25,5 @@ type Client interface { CreateComment(repo models.Repo, pullNum int, comment string) error PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error + GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) } diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go index 3e1e6a82c8..aac5148d18 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -178,3 +178,30 @@ func (g *GithubClient) min(a, b int) int { } return b } + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (g *GithubClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + var teamNames []string + opts := &github.ListOptions{} + org := repo.Owner + for { + teams, resp, err := g.client.Organizations.ListTeams(g.ctx, org, opts) + if err != nil { + return nil, err + } + for _, t := range teams { + ok, _, err := g.client.Organizations.IsTeamMember(g.ctx, t.GetID(), user.Username) + if err != nil { + return nil, err + } + if ok { + teamNames = append(teamNames, t.GetName()) + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return teamNames, nil +} diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go index 6563bd4b21..7fdf9270b9 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -103,3 +103,8 @@ func (g *GitlabClient) GetMergeRequest(repoFullName string, pullNum int) (*gitla mr, _, err := g.Client.MergeRequests.GetMergeRequest(repoFullName, pullNum) return mr, err } + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (g *GitlabClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, nil +} diff --git a/server/events/vcs/not_configured_vcs_client.go b/server/events/vcs/not_configured_vcs_client.go index a2381de263..abe39c008a 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -38,6 +38,9 @@ func (a *NotConfiguredVCSClient) PullIsApproved(repo models.Repo, pull models.Pu func (a *NotConfiguredVCSClient) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error { return a.err() } +func (a *NotConfiguredVCSClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, a.err() +} func (a *NotConfiguredVCSClient) err() error { //noinspection GoErrorStringFormat return fmt.Errorf("Atlantis was not configured to support repos from %s", a.Host.String()) diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index e981ee2a4e..b4c734c3e0 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -26,6 +26,7 @@ type ClientProxy interface { CreateComment(repo models.Repo, pullNum int, comment string) error PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error + GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) } // DefaultClientProxy proxies calls to the correct VCS client depending on which @@ -74,3 +75,7 @@ func (d *DefaultClientProxy) PullIsApproved(repo models.Repo, pull models.PullRe func (d *DefaultClientProxy) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error { return d.clients[repo.VCSHost.Type].UpdateStatus(repo, pull, state, description) } + +func (d *DefaultClientProxy) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return d.clients[repo.VCSHost.Type].GetTeamNamesForUser(repo, user) +} diff --git a/server/events_controller.go b/server/events_controller.go index 14643a7ebf..8551ec786b 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -57,6 +57,7 @@ type EventsController struct { // request validation is done. GitlabWebhookSecret []byte RepoWhitelistChecker *events.RepoWhitelistChecker + TeamWhitelistChecker *events.TeamWhitelistChecker // SupportedVCSHosts is which VCS hosts Atlantis was configured upon // startup to support. SupportedVCSHosts []models.VCSHostType @@ -372,6 +373,18 @@ func (e *EventsController) handleCommentEvent(w http.ResponseWriter, baseRepo mo return } + // Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands + ok, err := e.checkUserPermissions(baseRepo, user, parseResult.Command) + if err != nil { + e.Logger.Err("unable to comment on pull request: %s", err) + return + } + if !ok { + e.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, parseResult.Command) + e.respond(w, logging.Warn, http.StatusForbidden, "User @%s does not have permissions to execute '%s' command", user.Username, parseResult.Command.Name.String()) + return + } + e.Logger.Debug("executing command") fmt.Fprintln(w, "Processing...") if !e.TestingMode { @@ -423,3 +436,27 @@ func (e *EventsController) commentNotWhitelisted(baseRepo models.Repo, pullNum i e.Logger.Err("unable to comment on pull request: %s", err) } } + +// commentUserDoesNotHavePermissions comments on the pull request that the user +// is not allowed to execute the command. +func (e *EventsController) commentUserDoesNotHavePermissions(baseRepo models.Repo, pullNum int, user models.User, cmd *events.CommentCommand) { + errMsg := fmt.Sprintf("```\nError: User @%s does not have permissions to execute '%s' command.\n```", user.Username, cmd.Name) + if err := e.VCSClient.CreateComment(baseRepo, pullNum, errMsg); err != nil { + e.Logger.Err("unable to comment on pull request: %s", err) + } +} + +// checkUserPermissions checks if the user has permissions to execute the command +func (e *EventsController) checkUserPermissions(repo models.Repo, user models.User, cmd *events.CommentCommand) (bool, error) { + if cmd.Name == events.ApplyCommand || cmd.Name == events.PlanCommand { + teams, err := e.VCSClient.GetTeamNamesForUser(repo, user) + if err != nil { + return false, err + } + ok := e.TeamWhitelistChecker.IsCommandAllowedForAnyTeam(teams, cmd.Name.String()) + if !ok { + return false, nil + } + } + return true, nil +} diff --git a/server/server.go b/server/server.go index 947af013e9..c4f4354fc9 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,7 @@ type UserConfig struct { BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` DataDir string `mapstructure:"data-dir"` GithubHostname string `mapstructure:"gh-hostname"` + GithubTeamWhitelist string `mapstructure:"gh-team-whitelist"` GithubToken string `mapstructure:"gh-token"` GithubUser string `mapstructure:"gh-user"` GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` @@ -306,6 +307,10 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if err != nil { return nil, err } + githubTeamWhitelistChecker, err := events.NewTeamWhitelistChecker(userConfig.GithubTeamWhitelist) + if err != nil { + return nil, err + } locksController := &LocksController{ AtlantisVersion: config.AtlantisVersion, Locker: lockingClient, @@ -323,6 +328,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Logger: logger, GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), GithubRequestValidator: &DefaultGithubRequestValidator{}, + TeamWhitelistChecker: githubTeamWhitelistChecker, GitlabRequestParserValidator: &DefaultGitlabRequestParserValidator{}, GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), RepoWhitelistChecker: repoWhitelist, From 81ded0328c8961c1f776f9be69f5fcfa6da033ea Mon Sep 17 00:00:00 2001 From: aknysh Date: Fri, 12 Oct 2018 12:58:08 -0400 Subject: [PATCH 2/4] Update `description` --- cmd/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index 0cf11c8285..ff057179dc 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -117,8 +117,8 @@ var stringFlags = []stringFlag{ name: GHTeamWhitelistFlag, description: "Comma separated list of key-value pairs representing the GitHub teams and the operations that the members of a particular team is allowed to perform. " + "The format is {team}:{command},{team}:{command}, ex. dev:plan,ops:apply,devops:*. " + - "This example means to give the users from the 'dev' GitHub team the permissions to execute the 'plan' command, give the `ops` team the permissions to execute the 'apply' command, " + - "and allow the `devops` team to perform any operation. If this argument is not provided, the default value (*:*) will be used and the default behavior will be to not check permissions " + + "This example means to give the users from the 'dev' GitHub team the permissions to execute the 'plan' command, give the 'ops' team the permissions to execute the 'apply' command, " + + "and allow the 'devops' team to perform any operation. If this argument is not provided, the default value (*:*) will be used and the default behavior will be to not check permissions " + "and to allow users from any team to perform any operation.", defaultValue: DefaultGHTeamWhitelist, }, From 083f8b69e16a8a249d4c423d0f50efd80d8b6841 Mon Sep 17 00:00:00 2001 From: aknysh Date: Fri, 12 Oct 2018 13:11:56 -0400 Subject: [PATCH 3/4] Update `description` --- cmd/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/server.go b/cmd/server.go index ff057179dc..fb8868dc20 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -115,7 +115,7 @@ var stringFlags = []stringFlag{ }, { name: GHTeamWhitelistFlag, - description: "Comma separated list of key-value pairs representing the GitHub teams and the operations that the members of a particular team is allowed to perform. " + + description: "Comma separated list of key-value pairs representing the GitHub teams and the operations that the members of a particular team are allowed to perform. " + "The format is {team}:{command},{team}:{command}, ex. dev:plan,ops:apply,devops:*. " + "This example means to give the users from the 'dev' GitHub team the permissions to execute the 'plan' command, give the 'ops' team the permissions to execute the 'apply' command, " + "and allow the 'devops' team to perform any operation. If this argument is not provided, the default value (*:*) will be used and the default behavior will be to not check permissions " + From 93b54c15171b327300895287449a5b03f47b3fe2 Mon Sep 17 00:00:00 2001 From: aknysh Date: Fri, 12 Oct 2018 13:59:31 -0400 Subject: [PATCH 4/4] Address comments --- .travis.yml | 18 +++++++++--------- Makefile | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7c0254d47f..456bfd6adb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,20 +17,20 @@ services: install: - make init -- make travis:docker-login -- make go:deps-build -- make go:deps-dev +- make travis/docker-login +- make go/deps-build +- make go/deps-dev script: -- make go:deps -- make go:test -- make go:lint -- make go:build-all +- make go/deps +- make go/test +- make go/lint +- make go/build-all - ls -l release/ -- make docker:build +- make docker/build after_success: -- make travis:docker-tag-and-push +- make travis/docker-tag-and-push deploy: - provider: releases diff --git a/Makefile b/Makefile index f248845422..f056c5456b 100644 --- a/Makefile +++ b/Makefile @@ -87,8 +87,8 @@ end-to-end-tests: ## Run e2e tests website-dev: yarn website:dev -go-get: +go/get: go get -go-build: +go/build: CGO_ENABLED=0 go build -v -o "./dist/bin/atlantis" *.go