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

Store Gitlab groups in client struct #4898

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 12 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const (
GiteaUserFlag = "gitea-user"
GiteaWebhookSecretFlag = "gitea-webhook-secret" // nolint: gosec
GiteaPageSizeFlag = "gitea-page-size"
GitlabGroupAllowlistFlag = "gitlab-group-allowlist"
GitlabHostnameFlag = "gitlab-hostname"
GitlabTokenFlag = "gitlab-token"
GitlabUserFlag = "gitlab-user"
Expand Down Expand Up @@ -344,6 +345,17 @@ var stringFlags = map[string]stringFlag{
"This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " +
"Should be specified via the ATLANTIS_GITEA_WEBHOOK_SECRET environment variable.",
},
GitlabGroupAllowlistFlag: {
description: "Comma separated list of key-value pairs representing the GitLab groups and the operations that " +
"the members of a particular group are allowed to perform. " +
"The format is {group}:{command},{group}:{command}. " +
"Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'myorg/dev:plan,myorg/ops:apply,myorg/devops:*'" +
"This example gives the users from the 'myorg/dev' GitLab group the permissions to execute the 'plan' command, " +
"the 'myorg/ops' group the permissions to execute the 'apply' command, " +
"and allows the 'myorg/devops' group 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 group to perform any operation.",
},
GitlabHostnameFlag: {
description: "Hostname of your GitLab Enterprise installation. If using gitlab.com, no need to set.",
defaultValue: DefaultGitlabHostname,
Expand Down
1 change: 1 addition & 0 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ var testFlags = map[string]interface{}{
GiteaUserFlag: "gitea-user",
GiteaWebhookSecretFlag: "gitea-secret",
GiteaPageSizeFlag: 30,
GitlabGroupAllowlistFlag: "",
GitlabHostnameFlag: "gitlab-hostname",
GitlabTokenFlag: "gitlab-token",
GitlabUserFlag: "gitlab-user",
Expand Down
14 changes: 14 additions & 0 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,20 @@ based on the organization or user that triggered the webhook.
This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions.
:::

### `--gitlab-group-allowlist`
```bash
atlantis server --gitlab-group-allowlist="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
# or
ATLANTIS_GITLAB_GROUP_ALLOWLIST="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
```
Comma-separated list of GitLab groups and permission pairs.

By default, any group can plan and apply.

::: warning NOTE
Atlantis needs to be able to view the listed group members, inaccessible or non-existent groups are silently ignored.
:::

### `--gitlab-hostname`

```bash
Expand Down
12 changes: 6 additions & 6 deletions runatlantis.io/docs/server-side-repo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,12 +591,12 @@ mode: on_apply

### Policies

| Key | Type | Default | Required | Description |
|------------------------|-----------------|---------|-----------|----------------------------------------------------------|
| conftest_version | string | none | no | conftest version to run all policy sets |
| owners | Owners(#Owners) | none | yes | owners that can approve failing policies |
| approve_count | int | 1 | no | number of approvals required to bypass failing policies. |
| policy_sets | []PolicySet | none | yes | set of policies to run on a plan output |
| Key | Type | Default | Required | Description |
|------------------------|-----------------|---------|-----------|---------------------------------------------------------|
| conftest_version | string | none | no | conftest version to run all policy sets |
| owners | Owners(#Owners) | none | yes | owners that can approve failing policies |
| approve_count | int | 1 | no | number of approvals required to bypass failing policies |
| policy_sets | []PolicySet | none | yes | set of policies to run on a plan output |

### Owners

Expand Down
14 changes: 14 additions & 0 deletions server/core/config/valid/policies.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package valid

import (
"slices"
"strings"

version "github.com/hashicorp/go-version"
Expand Down Expand Up @@ -67,3 +68,16 @@ func (o *PolicyOwners) IsOwner(username string, userTeams []string) bool {

return false
}

// Return all owner teams from all policy sets
func (p *PolicySets) AllTeams() []string {
teams := p.Owners.Teams
for _, policySet := range p.PolicySets {
for _, team := range policySet.Owners.Teams {
if !slices.Contains(teams, team) {
teams = append(teams, team)
}
}
}
return teams
}
63 changes: 63 additions & 0 deletions server/core/config/valid/policies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,66 @@ func TestPoliciesConfig_IsOwners(t *testing.T) {
})
}
}

func TestPoliciesConfig_AllTeams(t *testing.T) {
cases := []struct {
description string
input valid.PolicySets
expResult []string
}{
{
description: "has only top-level team owner",
input: valid.PolicySets{
Owners: valid.PolicyOwners{
Teams: []string{
"team1",
},
},
},
expResult: []string{"team1"},
},
{
description: "has only policy-level team owner",
input: valid.PolicySets{
PolicySets: []valid.PolicySet{
{
Name: "policy1",
Owners: valid.PolicyOwners{
Teams: []string{
"team2",
},
},
},
},
},
expResult: []string{"team2"},
},
{
description: "has both top-level and policy-level team owners",
input: valid.PolicySets{
Owners: valid.PolicyOwners{
Teams: []string{
"team1",
},
},
PolicySets: []valid.PolicySet{
{
Name: "policy1",
Owners: valid.PolicyOwners{
Teams: []string{
"team2",
},
},
},
},
},
expResult: []string{"team1", "team2"},
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
result := c.input.AllTeams()
Equals(t, c.expResult, result)
})
}
}
21 changes: 17 additions & 4 deletions server/events/command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ type DefaultCommandRunner struct {
PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner
PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner
PullStatusFetcher PullStatusFetcher
TeamAllowlistChecker *TeamAllowlistChecker
GitHubTeamAllowlistChecker *TeamAllowlistChecker
GitLabGroupAllowlistChecker *TeamAllowlistChecker
VarFileAllowlistChecker *VarFileAllowlistChecker
CommitStatusUpdater CommitStatusUpdater
}
Expand Down Expand Up @@ -240,15 +241,27 @@ func (c *DefaultCommandRunner) commentUserDoesNotHavePermissions(baseRepo models

// checkUserPermissions checks if the user has permissions to execute the command
func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user models.User, cmdName string) (bool, error) {
if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() {
var teamAllowListChecker *TeamAllowlistChecker

switch repo.VCSHost.Type {
case models.Github:
teamAllowListChecker = c.GitHubTeamAllowlistChecker
case models.Gitlab:
teamAllowListChecker = c.GitLabGroupAllowlistChecker
default:
// allowlist restriction is not supported
return true, nil
}

if teamAllowListChecker == nil || !teamAllowListChecker.HasRules() {
// allowlist restriction is not enabled
return true, nil
}
teams, err := c.VCSClient.GetTeamNamesForUser(repo, user)
teams, err := c.VCSClient.GetTeamNamesForUser(c.Logger, repo, user)
if err != nil {
return false, err
}
ok := c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(teams, cmdName)
ok := teamAllowListChecker.IsCommandAllowedForAnyTeam(teams, cmdName)
if !ok {
return false, nil
}
Expand Down
8 changes: 4 additions & 4 deletions server/events/command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
t.Run("nil checker", func(t *testing.T) {
vcsClient := setup(t)
// by default these are false so don't need to reset
ch.TeamAllowlistChecker = nil
ch.GitHubTeamAllowlistChecker = nil
var pull github.PullRequest
modelPull := models.PullRequest{
BaseRepo: testdata.GithubRepo,
Expand All @@ -313,15 +313,15 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)

ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan})
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User)
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.User]())
vcsClient.VerifyWasCalledOnce().CreateComment(
Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan"))
})

t.Run("no rules", func(t *testing.T) {
vcsClient := setup(t)
// by default these are false so don't need to reset
ch.TeamAllowlistChecker = &events.TeamAllowlistChecker{}
ch.GitHubTeamAllowlistChecker = &events.TeamAllowlistChecker{}
var pull github.PullRequest
modelPull := models.PullRequest{
BaseRepo: testdata.GithubRepo,
Expand All @@ -331,7 +331,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)

ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan})
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User)
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.User]())
vcsClient.VerifyWasCalledOnce().CreateComment(
Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan"))
})
Expand Down
76 changes: 44 additions & 32 deletions server/events/mocks/mock_custom_step_runner.go

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

Loading
Loading