Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: --allow-commands restricts available atlantis commands #2877

Merged
merged 7 commits into from
Dec 28, 2022
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
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 @@ -93,6 +93,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 @@ -107,6 +109,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 @@ -192,6 +198,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 @@ -471,7 +490,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 @@ -496,7 +516,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 @@ -506,17 +530,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 @@ -542,7 +566,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 @@ -620,7 +644,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 @@ -863,7 +887,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 @@ -940,7 +964,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 @@ -960,10 +989,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 @@ -988,7 +1022,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