Skip to content

Commit

Permalink
feat: --allow-commands restricts available atlantis commands (#2877)
Browse files Browse the repository at this point in the history
* feat: --allow-command configuration restricts available atlantis commands

* add version and approve_policies into allow commands defaults

* allow-commands accept all keyword which allows all commands

* remove redundant nest

* more detail abount allow-commands all keyword

* Update server/events/comment_parser.go
  • Loading branch information
krrrr38 authored Dec 28, 2022
1 parent 27b9897 commit f09a9d4
Show file tree
Hide file tree
Showing 13 changed files with 511 additions and 77 deletions.
24 changes: 24 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/runatlantis/atlantis/server"
"github.com/runatlantis/atlantis/server/core/config/valid"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud"
"github.com/runatlantis/atlantis/server/logging"
)
Expand All @@ -43,6 +44,7 @@ const (
ADTokenFlag = "azuredevops-token" // nolint: gosec
ADUserFlag = "azuredevops-user"
ADHostnameFlag = "azuredevops-hostname"
AllowCommandsFlag = "allow-commands"
AllowForkPRsFlag = "allow-fork-prs"
AllowRepoConfigFlag = "allow-repo-config"
AtlantisURLFlag = "atlantis-url"
Expand Down Expand Up @@ -135,6 +137,7 @@ const (
DefaultADBasicPassword = ""
DefaultADHostname = "dev.azure.com"
DefaultAutoplanFileList = "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl"
DefaultAllowCommands = "version,plan,apply,unlock,approve_policies"
DefaultCheckoutStrategy = "branch"
DefaultBitbucketBaseURL = bitbucketcloud.BaseURL
DefaultDataDir = "~/.atlantis"
Expand Down Expand Up @@ -183,6 +186,10 @@ var stringFlags = map[string]stringFlag{
description: "Azure DevOps hostname to support cloud and self hosted instances.",
defaultValue: "dev.azure.com",
},
AllowCommandsFlag: {
description: "Comma separated list of acceptable atlantis commands.",
defaultValue: DefaultAllowCommands,
},
AtlantisURLFlag: {
description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.",
},
Expand Down Expand Up @@ -751,6 +758,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) {
if c.AutoplanFileList == "" {
c.AutoplanFileList = DefaultAutoplanFileList
}
if c.AllowCommands == "" {
c.AllowCommands = DefaultAllowCommands
}
if c.CheckoutStrategy == "" {
c.CheckoutStrategy = DefaultCheckoutStrategy
}
Expand Down Expand Up @@ -904,6 +914,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
return errors.Wrapf(patternErr, "invalid pattern in --%s, %s", AutoplanFileListFlag, userConfig.AutoplanFileList)
}

if _, err := userConfig.ToAllowCommandNames(); err != nil {
return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag)
}

return nil
}

Expand Down Expand Up @@ -1015,6 +1029,16 @@ func (s *ServerCmd) deprecationWarnings(userConfig *server.UserConfig) error {
deprecatedFlags = append(deprecatedFlags, RequireMergeableFlag)
commandReqs = append(commandReqs, valid.MergeableCommandReq)
}
if userConfig.DisableApply {
deprecatedFlags = append(deprecatedFlags, DisableApplyFlag)
var filtered []string
for _, allowCommand := range strings.Split(userConfig.AllowCommands, ",") {
if allowCommand != command.Apply.String() {
filtered = append(filtered, allowCommand)
}
}
userConfig.AllowCommands = strings.Join(filtered, ",")
}

// Build up strings with what the recommended yaml and json config should
// be instead of using the deprecated flags.
Expand Down
41 changes: 41 additions & 0 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ var testFlags = map[string]interface{}{
ADWebhookPasswordFlag: "ad-wh-pass",
ADWebhookUserFlag: "ad-wh-user",
AtlantisURLFlag: "url",
AllowCommandsFlag: "version,plan,unlock,import,approve_policies", // apply is disabled by DisableApply
AllowForkPRsFlag: true,
AllowRepoConfigFlag: true,
AutomergeFlag: true,
Expand Down Expand Up @@ -553,6 +554,36 @@ func TestExecute_ValidateVCSConfig(t *testing.T) {
}
}

func TestExecute_ValidateAllowCommands(t *testing.T) {
cases := []struct {
name string
allowCommandsFlag string
expErr string
}{
{
name: "invalid allow commands",
allowCommandsFlag: "noallow",
expErr: "invalid --allow-commands: unknown command name: noallow",
},
{
name: "success with empty allow commands",
allowCommandsFlag: "",
expErr: "",
},
}
for _, testCase := range cases {
c := setupWithDefaults(map[string]interface{}{
AllowCommandsFlag: testCase.allowCommandsFlag,
}, t)
err := c.Execute()
if testCase.expErr != "" {
ErrEquals(t, testCase.expErr, err)
} else {
Ok(t, err)
}
}
}

func TestExecute_ExpandHomeInDataDir(t *testing.T) {
t.Log("If ~ is used as a data-dir path, should expand to absolute home path")
c := setup(map[string]interface{}{
Expand Down Expand Up @@ -752,6 +783,16 @@ func TestExecute_BothSilenceAllowAndWhitelistErrors(t *testing.T) {
ErrEquals(t, "both --silence-allowlist-errors and --silence-whitelist-errors cannot be set–use --silence-allowlist-errors", err)
}

func TestExecute_DisableApplyDeprecation(t *testing.T) {
c := setupWithDefaults(map[string]interface{}{
DisableApplyFlag: true,
AllowCommandsFlag: "plan,apply,unlock",
}, t)
err := c.Execute()
Ok(t, err)
Equals(t, "plan,unlock", passedConfig.AllowCommands)
}

// Test that we set the corresponding allow list values on the userConfig
// struct if the deprecated whitelist flags are used.
func TestExecute_RepoWhitelistDeprecation(t *testing.T) {
Expand Down
16 changes: 16 additions & 0 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ Values are chosen in this order:


## Flags
### `--allow-commands`
```bash
atlantis server --allow-commands=version,plan,apply,unlock,approve_policies
# or
ATLANTIS_ALLOW_COMMANDS='version,plan,apply,unlock,approve_policies'
```
List of allowed commands to be run on the Atlantis server, Defaults to `version,plan,apply,unlock,approve_policies`

Notes:
* Accepts a comma separated list, ex. `command1,command2`.
* `version`, `plan`, `apply`, `unlock`, `approve_policies`, `import` and `all` are available.
* `all` is a special keyword that allows all commands. If pass `all` then all other commands will be ignored.

### `--allow-draft-prs`
```bash
atlantis server --allow-draft-prs
Expand Down Expand Up @@ -318,11 +331,14 @@ and set `--autoplan-modules` to `false`.
if not in `PATH`. See [Terraform Versions](terraform-versions.html) for more details.

### `--disable-apply`
<Badge text="Deprecated" type="warn"/>
```bash
atlantis server --disable-apply
# or
ATLANTIS_DISABLE_APPLY=true
```
Deprecated for `--allow-commands`.

Disable all `atlantis apply` commands, regardless of which flags are passed with it.

### `--disable-apply-all`
Expand Down
2 changes: 2 additions & 0 deletions runatlantis.io/docs/using-atlantis.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ atlantis import [options] ADDRESS ID -- [terraform import flags]
Runs `terraform import` that matches the directory/project/workspace.
This command discards the terraform plan result. After an import and before an apply, another `atlantis plan` must be run again.

To allow the `import` command requires [--allow-commands](/docs/server-configuration.html#allow-commands) configuration.

### Examples
```bash
# Runs import
Expand Down
56 changes: 45 additions & 11 deletions server/controllers/events/events_controller_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func TestGitHubWorkflow(t *testing.T) {
DisableApply bool
// ApplyLock creates an apply lock that temporarily disables apply command
ApplyLock bool
// AllowCommands flag what kind of atlantis commands are available.
AllowCommands []command.Name
// ExpAutomerge is true if we expect Atlantis to automerge.
ExpAutomerge bool
// ExpAutoplan is true if we expect Atlantis to autoplan.
Expand All @@ -108,6 +110,10 @@ func TestGitHubWorkflow(t *testing.T) {
// Atlantis writes to the pull request in order. A reply from a parallel operation
// will be matched using a substring check.
ExpReplies [][]string
// ExpAllowResponseCommentBack allow http response content with "Commenting back on pull request"
ExpAllowResponseCommentBack bool
// ExpParseFailedCount represents how many times test sends invalid commands
ExpParseFailedCount int
}{
{
Description: "simple",
Expand Down Expand Up @@ -193,6 +199,19 @@ func TestGitHubWorkflow(t *testing.T) {
{"exp-output-merge-workspaces.txt"},
},
},
{
Description: "simple with allow commands",
RepoDir: "simple",
AllowCommands: []command.Name{command.Plan, command.Apply},
Comments: []string{
"atlantis import ADDRESS ID",
},
ExpReplies: [][]string{
{"exp-output-allow-command-unknown-import.txt"},
},
ExpAllowResponseCommentBack: true,
ExpParseFailedCount: 1,
},
{
Description: "simple with atlantis.yaml",
RepoDir: "simple-yaml",
Expand Down Expand Up @@ -472,7 +491,8 @@ func TestGitHubWorkflow(t *testing.T) {
userConfig = server.UserConfig{}
userConfig.DisableApply = c.DisableApply

ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, c.RepoConfigFile)
opt := setupOption{repoConfigFile: c.RepoConfigFile, allowCommands: c.AllowCommands}
ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, opt)
// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA := initializeRepo(t, c.RepoDir)
atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir)
Expand All @@ -497,7 +517,11 @@ func TestGitHubWorkflow(t *testing.T) {
commentReq := GitHubCommentEvent(t, comment)
w = httptest.NewRecorder()
ctrl.Post(w, commentReq)
ResponseContains(t, w, 200, "Processing...")
if c.ExpAllowResponseCommentBack {
ResponseContains(t, w, 200, "Commenting back on pull request")
} else {
ResponseContains(t, w, 200, "Processing...")
}
}

// Send the "pull closed" event which would be triggered by the
Expand All @@ -507,17 +531,17 @@ func TestGitHubWorkflow(t *testing.T) {
ctrl.Post(w, pullClosedReq)
ResponseContains(t, w, 200, "Pull request cleaned successfully")

expNumHooks := len(c.Comments) + 1 - c.ExpParseFailedCount
// Let's verify the pre-workflow hook was called for each comment including the pull request opened event
mockPreWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString())

mockPreWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString())
// Let's verify the post-workflow hook was called for each comment including the pull request opened event
mockPostWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some post dummy command"), AnyString())
mockPostWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some post dummy command"), AnyString())

// Now we're ready to verify Atlantis made all the comments back (or
// replies) that we expect. We expect each plan to have 1 comment,
// and apply have 1 for each comment plus one for the locks deleted at the
// end.
expNumReplies := len(c.Comments) + 1
expNumReplies := len(c.Comments) + 1 - c.ExpParseFailedCount

if c.ExpAutoplan {
expNumReplies++
Expand All @@ -543,7 +567,7 @@ func TestGitHubWorkflow(t *testing.T) {
}
}

func TestSimlpleWorkflow_terraformLockFile(t *testing.T) {
func TestSimpleWorkflow_terraformLockFile(t *testing.T) {

if testing.Short() {
t.SkipNow()
Expand Down Expand Up @@ -621,7 +645,7 @@ func TestSimlpleWorkflow_terraformLockFile(t *testing.T) {
userConfig = server.UserConfig{}
userConfig.DisableApply = true

ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, "")
ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{})
// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA := initializeRepo(t, c.RepoDir)

Expand Down Expand Up @@ -864,7 +888,7 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
userConfig.EnablePolicyChecksFlag = true
userConfig.QuietPolicyChecks = c.ExpQuietPolicyChecks

ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, "")
ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{})

// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA := initializeRepo(t, c.RepoDir)
Expand Down Expand Up @@ -941,7 +965,12 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
}
}

func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
type setupOption struct {
repoConfigFile string
allowCommands []command.Name
}

func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
allowForkPRs := false
dataDir, binDir, cacheDir := mkSubDirs(t)

Expand All @@ -961,10 +990,15 @@ func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers.
GitlabUser: "gitlab-user",
GitlabToken: "gitlab-token",
}
allowCommands := command.AllCommentCommands
if opt.allowCommands != nil {
allowCommands = opt.allowCommands
}
commentParser := &events.CommentParser{
GithubUser: "github-user",
GitlabUser: "gitlab-user",
ExecutableName: "atlantis",
AllowCommands: allowCommands,
}
terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, true, false, projectCmdOutputHandler)
Ok(t, err)
Expand All @@ -989,7 +1023,7 @@ func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers.
parser := &config.ParserValidator{}

globalCfgArgs := valid.GlobalCfgArgs{
RepoConfigFile: repoConfigFile,
RepoConfigFile: opt.repoConfigFile,
AllowRepoCfg: true,
MergeableReq: false,
ApprovedReq: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
```
Error: unknown command "import".
Run 'atlantis --help' for usage.
Available commands(--allow-commands): plan, apply
```
32 changes: 32 additions & 0 deletions server/events/command/name.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package command

import (
"fmt"
"strings"

"golang.org/x/text/cases"
Expand Down Expand Up @@ -30,6 +31,16 @@ const (
// Adding more? Don't forget to update String() below
)

// AllCommentCommands are list of commands that can be run from a comment.
var AllCommentCommands = []Name{
Version,
Plan,
Apply,
Unlock,
ApprovePolicies,
Import,
}

// TitleString returns the string representation in title form.
// ie. policy_check becomes Policy Check
func (c Name) TitleString() string {
Expand Down Expand Up @@ -66,3 +77,24 @@ func (c Name) DefaultUsage() string {
return c.String()
}
}

// ParseCommandName parses raw name into a command name.
func ParseCommandName(name string) (Name, error) {
switch name {
case "apply":
return Apply, nil
case "plan":
return Plan, nil
case "unlock":
return Unlock, nil
case "policy_check":
return PolicyCheck, nil
case "approve_policies":
return ApprovePolicies, nil
case "version":
return Version, nil
case "import":
return Import, nil
}
return -1, fmt.Errorf("unknown command name: %s", name)
}
Loading

0 comments on commit f09a9d4

Please sign in to comment.