Skip to content

Commit

Permalink
Add GitHub team allowlist configuration option
Browse files Browse the repository at this point in the history
Co-authored-by: PePe (Jose) Amengual <[email protected]>
Co-authored-by: Troy Neeriemer <[email protected]>
Co-authored-by: Ted Roby <[email protected]>
Co-authored-by: Paul Erickson <[email protected]>
  • Loading branch information
4 people committed Jul 7, 2021
1 parent d914eaf commit b1f23dc
Show file tree
Hide file tree
Showing 16 changed files with 294 additions and 0 deletions.
17 changes: 17 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const (
EnablePolicyChecksFlag = "enable-policy-checks"
EnableRegExpCmdFlag = "enable-regexp-cmd"
GHHostnameFlag = "gh-hostname"
GHTeamAllowlistFlag = "gh-team-allowlist"
GHTokenFlag = "gh-token"
GHUserFlag = "gh-user"
GHAppIDFlag = "gh-app-id"
Expand Down Expand Up @@ -109,6 +110,7 @@ const (
DefaultBitbucketBaseURL = bitbucketcloud.BaseURL
DefaultDataDir = "~/.atlantis"
DefaultGHHostname = "github.com"
DefaultGHTeamAllowlist = "*:*"
DefaultGitlabHostname = "gitlab.com"
DefaultLogLevel = "info"
DefaultParallelPoolSize = 15
Expand Down Expand Up @@ -185,6 +187,18 @@ var stringFlags = map[string]stringFlag{
description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.",
defaultValue: DefaultGHHostname,
},
GHTeamAllowlistFlag: {
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: DefaultGHTeamAllowlist,
},
GHUserFlag: {
description: "GitHub username of API user.",
defaultValue: "",
Expand Down Expand Up @@ -612,6 +626,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) {
if c.VCSStatusName == "" {
c.VCSStatusName = DefaultVCSStatusName
}
if c.GithubTeamAllowlist == "" {
c.GithubTeamAllowlist = DefaultGHTeamAllowlist
}
if c.TFEHostname == "" {
c.TFEHostname = DefaultTFEHostname
}
Expand Down
39 changes: 39 additions & 0 deletions server/controllers/events/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type VCSEventsController struct {
// request validation is done.
GitlabWebhookSecret []byte
RepoAllowlistChecker *events.RepoAllowlistChecker
TeamAllowlistChecker *events.TeamAllowlistChecker
// SilenceAllowlistErrors controls whether we write an error comment on
// pull requests from non-allowlisted repos.
SilenceAllowlistErrors bool
Expand Down Expand Up @@ -436,6 +437,20 @@ func (e *VCSEventsController) handleCommentEvent(w http.ResponseWriter, baseRepo
return
}

// Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands
if !e.TestingMode {
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 @@ -553,3 +568,27 @@ func (e *VCSEventsController) commentNotAllowlisted(baseRepo models.Repo, pullNu
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 *VCSEventsController) 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 *VCSEventsController) 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.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(teams, cmd.Name.String())
if !ok {
return false, nil
}
}
return true, nil
}
72 changes: 72 additions & 0 deletions server/events/team_allowlist_checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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

// TeamAllowlistChecker implements checking the teams and the operations that the members
// of a particular team are allowed to perform
type TeamAllowlistChecker struct {
rules []mapOfStrings
}

// NewTeamAllowlistChecker constructs a new checker
func NewTeamAllowlistChecker(allowlist string) (*TeamAllowlistChecker, error) {
var rules []mapOfStrings
pairs := strings.Split(allowlist, ",")
if pairs[0] != "" {
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 &TeamAllowlistChecker{
rules: rules,
}, nil
}

// IsCommandAllowedForTeam returns true if the team is allowed to execute the command
// and false otherwise.
func (checker *TeamAllowlistChecker) 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 *TeamAllowlistChecker) IsCommandAllowedForAnyTeam(teams []string, command string) bool {
c := strings.TrimSpace(command)
if 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
}
35 changes: 35 additions & 0 deletions server/events/team_allowlist_checker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package events_test

import (
"testing"

"github.com/runatlantis/atlantis/server/events"
. "github.com/runatlantis/atlantis/testing"
)

func TestNewTeamAllowListChecker(t *testing.T) {
allowlist := `bob:plan, dave:apply`
_, err := events.NewTeamAllowlistChecker(allowlist)
Ok(t, err)
}

func TestIsCommandAllowedForTeam(t *testing.T) {
allowlist := `bob:plan, dave:apply, connie:plan, connie:apply`
checker, err := events.NewTeamAllowlistChecker(allowlist)
Ok(t, err)
Equals(t, true, checker.IsCommandAllowedForTeam("connie", "plan"))
Equals(t, true, checker.IsCommandAllowedForTeam("connie", "apply"))
Equals(t, true, checker.IsCommandAllowedForTeam("dave", "apply"))
Equals(t, true, checker.IsCommandAllowedForTeam("bob", "plan"))
Equals(t, false, checker.IsCommandAllowedForTeam("bob", "apply"))
}

func TestIsCommandAllowedForAnyTeam(t *testing.T) {
allowlist := `alpha:plan,beta:release`
teams := []string{`alpha`, `beta`}
checker, err := events.NewTeamAllowlistChecker(allowlist)
Ok(t, err)
Equals(t, true, checker.IsCommandAllowedForAnyTeam(teams, `plan`))
Equals(t, true, checker.IsCommandAllowedForAnyTeam(teams, `release`))
Equals(t, false, checker.IsCommandAllowedForAnyTeam(teams, `noop`))
}
5 changes: 5 additions & 0 deletions server/events/vcs/azuredevops_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ 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
}

func (g *AzureDevopsClient) SupportsSingleFileDownload(repo models.Repo) bool {
return false
}
Expand Down
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ 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 (b *Client) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) {
return nil, nil
}

func (b *Client) SupportsSingleFileDownload(models.Repo) bool {
return false
}
Expand Down
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketserver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ 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 (b *Client) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) {
return nil, nil
}

func (b *Client) SupportsSingleFileDownload(repo models.Repo) bool {
return false
}
Expand Down
1 change: 1 addition & 0 deletions server/events/vcs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Client interface {
UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error
MergePull(pull models.PullRequest, pullOptions models.PullRequestOptions) error
MarkdownPullLink(pull models.PullRequest) (string, error)
GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error)

// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository)
// The first return value indicate that repo contain atlantis.yaml or not
Expand Down
27 changes: 27 additions & 0 deletions server/events/vcs/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,33 @@ 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.GetTeamMembershipBySlug(g.ctx, org, *t.Slug, 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
}

// ExchangeCode returns a newly created app's info
func (g *GithubClient) ExchangeCode(code string) (*GithubAppTemporarySecrets, error) {
ctx := context.Background()
Expand Down
5 changes: 5 additions & 0 deletions server/events/vcs/gitlab_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ 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
}

// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository)
// The first return value indicate that repo contain atlantis.yaml or not
// if BaseRepo had one repo config file, its content will placed on the second return value
Expand Down
20 changes: 20 additions & 0 deletions server/events/vcs/mocks/matchers/models_user.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions server/events/vcs/mocks/mock_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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,6 +53,9 @@ 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()
}

func (a *NotConfiguredVCSClient) SupportsSingleFileDownload(repo models.Repo) bool {
return false
Expand Down
Loading

0 comments on commit b1f23dc

Please sign in to comment.