Skip to content
This repository has been archived by the owner on Jan 11, 2022. It is now read-only.

Multienv and ghteams #1

Merged
merged 2 commits into from
Sep 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +91,7 @@ const (
DefaultBitbucketBaseURL = bitbucketcloud.BaseURL
DefaultDataDir = "~/.atlantis"
DefaultGHHostname = "github.com"
DefaultGHTeamWhitelist = "*:*"
DefaultGitlabHostname = "gitlab.com"
DefaultLogLevel = "info"
DefaultPort = 4141
Expand Down Expand Up @@ -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.",
},
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 0 additions & 1 deletion e2e/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (

"fmt"

"github.com/google/go-github/v28/github"
multierror "github.com/hashicorp/go-multierror"
)

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 2 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions server/events/team_whitelist_checker.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions server/events/vcs/azuredevops_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketserver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions server/events/vcs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 27 additions & 0 deletions server/events/vcs/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/gitlab_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions server/events/vcs/not_configured_vcs_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
4 changes: 4 additions & 0 deletions server/events/vcs/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 10 additions & 2 deletions server/events/yaml/parser_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -82,7 +91,6 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global
return validConfig, err
}
}

err = globalCfg.ValidateRepoCfg(validConfig, repoID)
return validConfig, err
}
Expand Down
37 changes: 37 additions & 0 deletions server/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down