Skip to content

Commit d2dc28a

Browse files
committed
feat: add support for GitLab groups
1 parent 6ff0e2f commit d2dc28a

24 files changed

+256
-41
lines changed

cmd/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const (
9595
GHOrganizationFlag = "gh-org"
9696
GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec
9797
GHAllowMergeableBypassApply = "gh-allow-mergeable-bypass-apply" // nolint: gosec
98+
GitlabGroupAllowlistFlag = "gitlab-group-allowlist"
9899
GitlabHostnameFlag = "gitlab-hostname"
99100
GitlabTokenFlag = "gitlab-token"
100101
GitlabUserFlag = "gitlab-user"
@@ -314,6 +315,17 @@ var stringFlags = map[string]stringFlag{
314315
"This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " +
315316
"Should be specified via the ATLANTIS_GH_WEBHOOK_SECRET environment variable.",
316317
},
318+
GitlabGroupAllowlistFlag: {
319+
description: "Comma separated list of key-value pairs representing the GitLab groups and the operations that " +
320+
"the members of a particular group are allowed to perform. " +
321+
"The format is {group}:{command},{group}:{command}. " +
322+
"Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'myorg/dev:plan,myorg/ops:apply,myorg/devops:*'" +
323+
"This example gives the users from the 'myorg/dev' GitLab group the permissions to execute the 'plan' command, " +
324+
"the 'myorg/ops' group the permissions to execute the 'apply' command, " +
325+
"and allows the 'myorg/devops' group to perform any operation. If this argument is not provided, the default value (*:*) " +
326+
"will be used and the default behavior will be to not check permissions " +
327+
"and to allow users from any group to perform any operation.",
328+
},
317329
GitlabHostnameFlag: {
318330
description: "Hostname of your GitLab Enterprise installation. If using gitlab.com, no need to set.",
319331
defaultValue: DefaultGitlabHostname,

runatlantis.io/docs/server-configuration.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,20 @@ and set `--autoplan-modules` to `false`.
581581
This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions.
582582
:::
583583

584+
### `--gitlab-group-allowlist`
585+
```bash
586+
atlantis server --gitlab-group-allowlist="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
587+
# or
588+
ATLANTIS_GITLAB_GROUP_ALLOWLIST="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
589+
```
590+
Comma-separated list of GitLab groups and permission pairs.
591+
592+
By default, any group can plan and apply.
593+
594+
::: warning NOTE
595+
Atlantis needs to be able to view the listed group members, inaccessible or non-existent groups are silently ignored.
596+
:::
597+
584598
### `--gitlab-hostname`
585599
```bash
586600
atlantis server --gitlab-hostname="my.gitlab.enterprise.com"

runatlantis.io/docs/server-side-repo-config.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -531,18 +531,18 @@ If you set a workflow with the key `default`, it will override this.
531531

532532
### Policies
533533

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

541541
### Owners
542-
| Key | Type | Default | Required | Description |
543-
|-------------|-------------------|---------|------------|---------------------------------------------------------|
544-
| users | []string | none | no | list of github users that can approve failing policies |
545-
| teams | []string | none | no | list of github teams that can approve failing policies |
542+
| Key | Type | Default | Required | Description |
543+
|-------------|-------------------|---------|------------|-------------------------------------------------------------------------|
544+
| users | []string | none | no | list of GitHub or GitLab users that can approve failing policies |
545+
| teams | []string | none | no | list of GitHub teams or GitLab groups that can approve failing policies |
546546

547547
### PolicySet
548548

server/core/config/valid/policies.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package valid
22

33
import (
4+
"slices"
45
"strings"
56

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

6768
return false
6869
}
70+
71+
// Return all owner teams from all policy sets
72+
func (p *PolicySets) AllTeams() []string {
73+
teams := p.Owners.Teams
74+
for _, policySet := range p.PolicySets {
75+
for _, team := range policySet.Owners.Teams {
76+
if !slices.Contains(teams, team) {
77+
teams = append(teams, team)
78+
}
79+
}
80+
}
81+
return teams
82+
}

server/core/config/valid/policies_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,66 @@ func TestPoliciesConfig_IsOwners(t *testing.T) {
120120
})
121121
}
122122
}
123+
124+
func TestPoliciesConfig_AllTeams(t *testing.T) {
125+
cases := []struct {
126+
description string
127+
input valid.PolicySets
128+
expResult []string
129+
}{
130+
{
131+
description: "has only top-level team owner",
132+
input: valid.PolicySets{
133+
Owners: valid.PolicyOwners{
134+
Teams: []string{
135+
"team1",
136+
},
137+
},
138+
},
139+
expResult: []string{"team1"},
140+
},
141+
{
142+
description: "has only policy-level team owner",
143+
input: valid.PolicySets{
144+
PolicySets: []valid.PolicySet{
145+
{
146+
Name: "policy1",
147+
Owners: valid.PolicyOwners{
148+
Teams: []string{
149+
"team2",
150+
},
151+
},
152+
},
153+
},
154+
},
155+
expResult: []string{"team2"},
156+
},
157+
{
158+
description: "has both top-level and policy-level team owners",
159+
input: valid.PolicySets{
160+
Owners: valid.PolicyOwners{
161+
Teams: []string{
162+
"team1",
163+
},
164+
},
165+
PolicySets: []valid.PolicySet{
166+
{
167+
Name: "policy1",
168+
Owners: valid.PolicyOwners{
169+
Teams: []string{
170+
"team2",
171+
},
172+
},
173+
},
174+
},
175+
},
176+
expResult: []string{"team1", "team2"},
177+
},
178+
}
179+
for _, c := range cases {
180+
t.Run(c.description, func(t *testing.T) {
181+
result := c.input.AllTeams()
182+
Equals(t, c.expResult, result)
183+
})
184+
}
185+
}

server/events/command_runner.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ type DefaultCommandRunner struct {
126126
PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner
127127
PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner
128128
PullStatusFetcher PullStatusFetcher
129-
TeamAllowlistChecker *TeamAllowlistChecker
129+
GitHubTeamAllowlistChecker *TeamAllowlistChecker
130+
GitLabGroupAllowlistChecker *TeamAllowlistChecker
130131
VarFileAllowlistChecker *VarFileAllowlistChecker
131132
CommitStatusUpdater CommitStatusUpdater
132133
}
@@ -238,15 +239,27 @@ func (c *DefaultCommandRunner) commentUserDoesNotHavePermissions(baseRepo models
238239

239240
// checkUserPermissions checks if the user has permissions to execute the command
240241
func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user models.User, cmdName string) (bool, error) {
241-
if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() {
242+
var teamAllowListChecker *TeamAllowlistChecker
243+
244+
switch repo.VCSHost.Type {
245+
case models.Github:
246+
teamAllowListChecker = c.GitHubTeamAllowlistChecker
247+
case models.Gitlab:
248+
teamAllowListChecker = c.GitLabGroupAllowlistChecker
249+
default:
250+
// allowlist restriction is not supported
251+
return true, nil
252+
}
253+
254+
if teamAllowListChecker == nil || !teamAllowListChecker.HasRules() {
242255
// allowlist restriction is not enabled
243256
return true, nil
244257
}
245-
teams, err := c.VCSClient.GetTeamNamesForUser(repo, user)
258+
teams, err := c.VCSClient.GetTeamNamesForUser(repo, user, teamAllowListChecker.AllTeamsForCommand(cmdName))
246259
if err != nil {
247260
return false, err
248261
}
249-
ok := c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(teams, cmdName)
262+
ok := teamAllowListChecker.IsCommandAllowedForAnyTeam(teams, cmdName)
250263
if !ok {
251264
return false, nil
252265
}

server/events/command_runner_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
298298
t.Run("nil checker", func(t *testing.T) {
299299
vcsClient := setup(t)
300300
// by default these are false so don't need to reset
301-
ch.TeamAllowlistChecker = nil
301+
ch.GitHubTeamAllowlistChecker = nil
302302
var pull github.PullRequest
303303
modelPull := models.PullRequest{
304304
BaseRepo: testdata.GithubRepo,
@@ -308,14 +308,14 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
308308
When(eventParsing.ParseGithubPull(&pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)
309309

310310
ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan})
311-
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User)
311+
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User, []string{})
312312
vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, modelPull.Num, "Ran Plan for 0 projects:", "plan")
313313
})
314314

315315
t.Run("no rules", func(t *testing.T) {
316316
vcsClient := setup(t)
317317
// by default these are false so don't need to reset
318-
ch.TeamAllowlistChecker = &events.TeamAllowlistChecker{}
318+
ch.GitHubTeamAllowlistChecker = &events.TeamAllowlistChecker{}
319319
var pull github.PullRequest
320320
modelPull := models.PullRequest{
321321
BaseRepo: testdata.GithubRepo,
@@ -325,7 +325,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
325325
When(eventParsing.ParseGithubPull(&pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)
326326

327327
ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan})
328-
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User)
328+
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User, []string{})
329329
vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, modelPull.Num, "Ran Plan for 0 projects:", "plan")
330330
})
331331
}

server/events/project_command_runner.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ func (p *DefaultProjectCommandRunner) doApprovePolicies(ctx command.ProjectConte
343343
// Only query the users team membership if any teams have been configured as owners on any policy set(s).
344344
if policySetCfg.HasTeamOwners() {
345345
// A convenient way to access vcsClient. Not sure if best way.
346-
userTeams, err := p.VcsClient.GetTeamNamesForUser(ctx.Pull.BaseRepo, ctx.User)
346+
userTeams, err := p.VcsClient.GetTeamNamesForUser(ctx.Pull.BaseRepo, ctx.User, policySetCfg.AllTeams())
347347
if err != nil {
348348
ctx.Log.Err("unable to get team membership for user: %s", err)
349349
return nil, "", err

server/events/project_command_runner_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ func TestDefaultProjectCommandRunner_ApprovePolicies(t *testing.T) {
12311231
}
12321232

12331233
modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num}
1234-
When(runner.VcsClient.GetTeamNamesForUser(testdata.GithubRepo, testdata.User)).ThenReturn(c.userTeams, nil)
1234+
When(runner.VcsClient.GetTeamNamesForUser(testdata.GithubRepo, testdata.User, c.policySetCfg.AllTeams())).ThenReturn(c.userTeams, nil)
12351235
ctx := command.ProjectContext{
12361236
User: testdata.User,
12371237
Log: logging.NewNoopLogger(t),

server/events/team_allowlist_checker.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,16 @@ func (checker *TeamAllowlistChecker) IsCommandAllowedForAnyTeam(teams []string,
7171
}
7272
return false
7373
}
74+
75+
// AllTeams returns all teams listed in the rule for a command
76+
func (checker *TeamAllowlistChecker) AllTeamsForCommand(command string) []string {
77+
var teamNames []string
78+
for _, rule := range checker.rules {
79+
for key, value := range rule {
80+
if strings.EqualFold(value, command) {
81+
teamNames = append(teamNames, key)
82+
}
83+
}
84+
}
85+
return teamNames
86+
}

0 commit comments

Comments
 (0)