Skip to content

Commit cf84095

Browse files
committed
feat: add support for GitLab groups
Signed-off-by: Pierre Guinoiseau <[email protected]>
1 parent aa3d972 commit cf84095

29 files changed

+317
-54
lines changed

cmd/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const (
106106
GiteaUserFlag = "gitea-user"
107107
GiteaWebhookSecretFlag = "gitea-webhook-secret" // nolint: gosec
108108
GiteaPageSizeFlag = "gitea-page-size"
109+
GitlabGroupAllowlistFlag = "gitlab-group-allowlist"
109110
GitlabHostnameFlag = "gitlab-hostname"
110111
GitlabTokenFlag = "gitlab-token"
111112
GitlabUserFlag = "gitlab-user"
@@ -358,6 +359,17 @@ var stringFlags = map[string]stringFlag{
358359
"This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " +
359360
"Should be specified via the ATLANTIS_GITEA_WEBHOOK_SECRET environment variable.",
360361
},
362+
GitlabGroupAllowlistFlag: {
363+
description: "Comma separated list of key-value pairs representing the GitLab groups and the operations that " +
364+
"the members of a particular group are allowed to perform. " +
365+
"The format is {group}:{command},{group}:{command}. " +
366+
"Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'myorg/dev:plan,myorg/ops:apply,myorg/devops:*'" +
367+
"This example gives the users from the 'myorg/dev' GitLab group the permissions to execute the 'plan' command, " +
368+
"the 'myorg/ops' group the permissions to execute the 'apply' command, " +
369+
"and allows the 'myorg/devops' group to perform any operation. If this argument is not provided, the default value (*:*) " +
370+
"will be used and the default behavior will be to not check permissions " +
371+
"and to allow users from any group to perform any operation.",
372+
},
361373
GitlabHostnameFlag: {
362374
description: "Hostname of your GitLab Enterprise installation. If using gitlab.com, no need to set.",
363375
defaultValue: DefaultGitlabHostname,

cmd/server_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ var testFlags = map[string]interface{}{
100100
GiteaUserFlag: "gitea-user",
101101
GiteaWebhookSecretFlag: "gitea-secret",
102102
GiteaPageSizeFlag: 30,
103+
GitlabGroupAllowlistFlag: "",
103104
GitlabHostnameFlag: "gitlab-hostname",
104105
GitlabTokenFlag: "gitlab-token",
105106
GitlabUserFlag: "gitlab-user",

runatlantis.io/docs/server-configuration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,22 @@ based on the organization or user that triggered the webhook.
770770
This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions.
771771
:::
772772

773+
### `--gitlab-group-allowlist`
774+
775+
```bash
776+
atlantis server --gitlab-group-allowlist="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
777+
# or
778+
ATLANTIS_GITLAB_GROUP_ALLOWLIST="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import"
779+
```
780+
781+
Comma-separated list of GitLab groups and permission pairs.
782+
783+
By default, any group can plan and apply.
784+
785+
::: warning NOTE
786+
Atlantis needs to be able to view the listed group members, inaccessible or non-existent groups are silently ignored.
787+
:::
788+
773789
### `--gitlab-hostname`
774790

775791
```bash

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,12 +592,12 @@ mode: on_apply
592592

593593
### Policies
594594

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

602602
### Owners
603603

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"
@@ -67,3 +68,16 @@ func (o *PolicyOwners) IsOwner(username string, userTeams []string) bool {
6768

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

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/team_allowlist_checker.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ type TeamAllowlistChecker interface {
2121

2222
// IsCommandAllowedForAnyTeam determines if any of the specified teams can perform the specified action
2323
IsCommandAllowedForAnyTeam(ctx models.TeamAllowlistCheckerContext, teams []string, command string) bool
24+
25+
// AllTeams returns all teams configured in the allowlist
26+
AllTeams() []string
2427
}
2528

2629
// DefaultTeamAllowlistChecker implements checking the teams and the operations that the members
@@ -84,3 +87,14 @@ func (checker *DefaultTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx model
8487
}
8588
return false
8689
}
90+
91+
// AllTeams returns all teams configured in the allowlist
92+
func (checker *DefaultTeamAllowlistChecker) AllTeams() []string {
93+
var teamNames []string
94+
for _, rule := range checker.rules {
95+
for key := range rule {
96+
teamNames = append(teamNames, key)
97+
}
98+
}
99+
return teamNames
100+
}

server/events/command_runner.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo
157157

158158
// Check if the user who triggered the autoplan has permissions to run 'plan'.
159159
if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() {
160-
err := c.fetchUserTeams(baseRepo, &user)
160+
err := c.fetchUserTeams(log, baseRepo, &user)
161161
if err != nil {
162162
log.Err("Unable to fetch user teams: %s", err)
163163
return
@@ -300,7 +300,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead
300300

301301
// Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands
302302
if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() {
303-
err := c.fetchUserTeams(baseRepo, &user)
303+
err := c.fetchUserTeams(log, baseRepo, &user)
304304
if err != nil {
305305
c.Logger.Err("Unable to fetch user teams: %s", err)
306306
return
@@ -491,8 +491,8 @@ func (c *DefaultCommandRunner) ensureValidRepoMetadata(
491491
return
492492
}
493493

494-
func (c *DefaultCommandRunner) fetchUserTeams(repo models.Repo, user *models.User) error {
495-
teams, err := c.VCSClient.GetTeamNamesForUser(repo, *user)
494+
func (c *DefaultCommandRunner) fetchUserTeams(logger logging.SimpleLogging, repo models.Repo, user *models.User) error {
495+
teams, err := c.VCSClient.GetTeamNamesForUser(logger, repo, *user)
496496
if err != nil {
497497
return err
498498
}

server/events/command_runner_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
313313
When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)
314314

315315
ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan})
316-
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User)
316+
vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(ch.Logger, testdata.GithubRepo, testdata.User)
317317
vcsClient.VerifyWasCalledOnce().CreateComment(
318318
Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan"))
319319
})
@@ -331,7 +331,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) {
331331
When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil)
332332

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

server/events/external_team_allowlist_checker.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func (checker *ExternalTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx mode
3939
return checker.checkOutputResults(out)
4040
}
4141

42+
func (checker *ExternalTeamAllowlistChecker) AllTeams() []string {
43+
return []string{}
44+
}
45+
4246
func (checker *ExternalTeamAllowlistChecker) buildCommandString(ctx models.TeamAllowlistCheckerContext, teams []string, command string) string {
4347
// Build command string
4448
// Format is "$external_cmd $external_args $command $repo $teams"

0 commit comments

Comments
 (0)