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

Add gh-team-whitelist command line argument #7

Closed
wants to merge 4 commits into from
Closed
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ output
.terraform/
node_modules/
**/.vuepress/dist
helm/test-values.yaml
helm/test-values.yaml
dist/
42 changes: 42 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -62,6 +63,7 @@ const (
DefaultBitbucketBaseURL = bitbucketcloud.BaseURL
DefaultDataDir = "~/.atlantis"
DefaultGHHostname = "github.com"
DefaultGHTeamWhitelist = "*:*"
DefaultGitlabHostname = "gitlab.com"
DefaultLogLevel = "info"
DefaultPort = 4141
Expand Down Expand Up @@ -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 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 " +
"and to allow users from any team to perform any operation.",
defaultValue: DefaultGHTeamWhitelist,
},
{
name: GHUserFlag,
description: "GitHub username of API user.",
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion helm/atlantis/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"github.com/spf13/viper"
)

const atlantisVersion = "0.4.10"
const atlantisVersion = "0.5.0"

func main() {
v := viper.New()
Expand Down
83 changes: 83 additions & 0 deletions server/events/team_whitelist_checker.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/bitbucketserver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions server/events/vcs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 27 additions & 0 deletions server/events/vcs/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions server/events/vcs/gitlab_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
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 @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions server/events/vcs/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
37 changes: 37 additions & 0 deletions server/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down