diff --git a/cmd/server.go b/cmd/server.go index 9bfab8e2c8..5a7955550e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -55,6 +55,7 @@ const ( DisableApplyAllFlag = "disable-apply-all" DisableMarkdownFoldingFlag = "disable-markdown-folding" GHHostnameFlag = "gh-hostname" + GHTeamWhitelistFlag = "gh-team-whitelist" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec @@ -90,6 +91,7 @@ const ( DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" DefaultGHHostname = "github.com" + DefaultGHTeamWhitelist = "*:*" DefaultGitlabHostname = "gitlab.com" DefaultLogLevel = "info" DefaultPort = 4141 @@ -158,6 +160,18 @@ var stringFlags = map[string]stringFlag{ description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.", defaultValue: DefaultGHHostname, }, + GHTeamWhitelistFlag: { + 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}. " + + "Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'dev:plan,ops:apply,devops:*'" + + "This example gives the users from the 'dev' GitHub team the permissions to execute the 'plan' command, " + + "the 'ops' team the permissions to execute the 'apply' command, " + + "and allows 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, + }, GHUserFlag: { description: "GitHub username of API user.", }, @@ -502,6 +516,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.VCSStatusName == "" { c.VCSStatusName = DefaultVCSStatusName } + if c.GithubTeamWhitelist == "" { + c.GithubTeamWhitelist = DefaultGHTeamWhitelist + } if c.TFEHostname == "" { c.TFEHostname = DefaultTFEHostname } diff --git a/e2e/main.go b/e2e/main.go index ee837d9908..7bb03a5f41 100644 --- a/e2e/main.go +++ b/e2e/main.go @@ -21,7 +21,6 @@ import ( "fmt" - "github.com/google/go-github/v28/github" multierror "github.com/hashicorp/go-multierror" ) diff --git a/go.mod b/go.mod index cb24c12ac3..60812b1be0 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/go-playground/locales v0.12.1 // indirect github.com/go-playground/universal-translator v0.16.0 // indirect github.com/go-test/deep v1.0.3 + github.com/google/go-github/v28 v28.1.1 github.com/google/go-github/v31 v31.0.0 github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c // indirect github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect diff --git a/go.sum b/go.sum index 2e13225c0a..1d46ead906 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,11 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github/v28 v28.0.0 h1:+UjHI4+1W/vsXR4jJBWt0ZA74XHbvt5yBAvsf1M3bgM= github.com/google/go-github/v28 v28.0.0/go.mod h1:+5GboIspo7F0NG2qsvfYh7en6F3EK37uyqv+c35AR3s= +github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= +github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= diff --git a/main.go b/main.go index 87cf52815d..c8103ef5dd 100644 --- a/main.go +++ b/main.go @@ -20,9 +20,10 @@ import ( "github.com/spf13/viper" ) -const atlantisVersion = "0.13.0" +const atlantisVersion = "0.12.0-multienv-ght" func main() { + v := viper.New() // We're creating commands manually here rather than using init() functions diff --git a/server/events/team_whitelist_checker.go b/server/events/team_whitelist_checker.go new file mode 100644 index 0000000000..9ac9880465 --- /dev/null +++ b/server/events/team_whitelist_checker.go @@ -0,0 +1,70 @@ +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/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 1e75f8f9c7..e41880e3b3 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -364,3 +364,7 @@ func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project st } return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] } +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (g *AzureDevopsClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, nil +} diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index b8bcb8d16a..1908a2b4b5 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -243,3 +243,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 10653439e4..7fbad58060 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -311,3 +311,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 6cd35b39a4..d550c6cc27 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -38,4 +38,5 @@ type Client interface { UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error MergePull(pull models.PullRequest) error MarkdownPullLink(pull models.PullRequest) (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 10bd600afe..2de888d624 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -335,3 +335,30 @@ func (g *GithubClient) MergePull(pull models.PullRequest) error { func (g *GithubClient) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil } + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +// https://developer.github.com/v3/teams/members/#get-team-membership +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.Teams.ListTeams(g.ctx, org, opts) + if err != nil { + return nil, err + } + for _, t := range teams { + membership, _, err := g.client.Teams.GetTeamMembership(g.ctx, t.GetID(), user.Username) + if err == nil && membership != nil { + if *membership.State == "active" && (*membership.Role == "member" || *membership.Role == "maintainer") { + 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 076a1a60ab..3e05802998 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -263,3 +263,8 @@ func MustConstraint(constraint string) version.Constraints { } return c } + +// 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 6a5b3d747b..422ee8044a 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -53,3 +53,6 @@ func (a *NotConfiguredVCSClient) MarkdownPullLink(pull models.PullRequest) (stri func (a *NotConfiguredVCSClient) err() error { return fmt.Errorf("atlantis was not configured to support repos from %s", a.Host.String()) } +func (a *NotConfiguredVCSClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return nil, a.err() +} diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index ed25c4d60d..90ff3257ad 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -83,3 +83,7 @@ func (d *ClientProxy) MergePull(pull models.PullRequest) error { func (d *ClientProxy) MarkdownPullLink(pull models.PullRequest) (string, error) { return d.clients[pull.BaseRepo.VCSHost.Type].MarkdownPullLink(pull) } + +func (d *ClientProxy) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + return d.clients[repo.VCSHost.Type].GetTeamNamesForUser(repo, user) +} diff --git a/server/events/yaml/parser_validator.go b/server/events/yaml/parser_validator.go index 0e2bd84a24..048c7efd3b 100644 --- a/server/events/yaml/parser_validator.go +++ b/server/events/yaml/parser_validator.go @@ -17,7 +17,16 @@ import ( ) // AtlantisYAMLFilename is the name of the config file for each repo. -const AtlantisYAMLFilename = "atlantis.yaml" +var AtlantisYAMLFilename string + +// Simplest hack to allow overriding "atlantis.yaml" to another name +func init() { + AtlantisYAMLFilename = os.Getenv("ATLANTIS_YAML_FILENAME") + if AtlantisYAMLFilename == "" { + AtlantisYAMLFilename = "atlantis.yaml" + } +} + // ParserValidator parses and validates server-side repo config files and // repo-level atlantis.yaml files. @@ -82,7 +91,6 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global return validConfig, err } } - err = globalCfg.ValidateRepoCfg(validConfig, repoID) return validConfig, err } diff --git a/server/events_controller.go b/server/events_controller.go index 2b62a2017d..c393f97145 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -61,6 +61,7 @@ type EventsController struct { // request validation is done. GitlabWebhookSecret []byte RepoWhitelistChecker *events.RepoWhitelistChecker + TeamWhitelistChecker *events.TeamWhitelistChecker // SilenceWhitelistErrors controls whether we write an error comment on // pull requests from non-whitelisted repos. SilenceWhitelistErrors bool @@ -435,6 +436,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 { @@ -552,3 +565,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 == models.ApplyCommand || cmd.Name == models.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 c5ef287542..8c047018fa 100644 --- a/server/server.go +++ b/server/server.go @@ -378,6 +378,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, AtlantisURL: parsedURL, @@ -397,6 +401,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, diff --git a/server/user_config.go b/server/user_config.go index 0355a45633..6d398b224c 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -29,6 +29,7 @@ type UserConfig struct { GithubUser string `mapstructure:"gh-user"` GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` GitlabHostname string `mapstructure:"gitlab-hostname"` + GithubTeamWhitelist string `mapstructure:"gh-team-whitelist"` GitlabToken string `mapstructure:"gitlab-token"` GitlabUser string `mapstructure:"gitlab-user"` GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"`