Repository: runatlantis/atlantis (language: go)

Issue:
Support GitHub Teams for conftest policies
I would like to restrict planning (or applying) for certain users in our github organization based on their github team membership.

Can I do this using the latest beta release? https://github.com/runatlantis/atlantis/releases/tag/v0.17.0-beta


Discussion / hints:
Yeah definitely possible, I think you'd just need to implement retrieval of that data and pass it into confitest so conftest can run that validation.  

However, this will only restrict the apply really. You can't restrict the plan using conftest.
>I think you'd just need to implement retrieval of that data and pass it into confitest so conftest can run that validation.

Is there an example of this in the atlantis docs?
no like this would be a brand new feature
Looking forward for the feature

`Teams` would need to be a part of `PolicyOwners`
https://github.com/runatlantis/atlantis/blob/44ab8f216cfa212a8dcfe40213bcdfe3601440cf/server/events/yaml/raw/policies.go#L41-L43

Fix patch:
diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md
index 05d320b505..3a40bcef1a 100644
--- a/runatlantis.io/docs/access-credentials.md
+++ b/runatlantis.io/docs/access-credentials.md
@@ -57,6 +57,8 @@ GitHub App needs these permissions. These are automatically set when a GitHub ap
 
 ::: tip NOTE
 Since v0.19.7, a new permission for `Administration` has been added. If you have already created a GitHub app, updating Atlantis to v0.19.7 will not automatically add this permission, so you will need to set it manually.
+
+Since v0.22.3, a new permission for `Members` has been added, which is required for features that apply permissions to an organizations team members rather than individual users. Like the `Administration` permission above, updating Atlantis will not automatically add this permission, so if you wish to use features that rely on checking team membership you will need to add this manually.
 :::
 
 | Type            | Access              | 
@@ -69,6 +71,7 @@ Since v0.19.7, a new permission for `Administration` has been added. If you have
 | Metadata        | Read-only (default) | 
 | Pull requests   | Read and write      | 
 | Webhooks        | Read and write      | 
+| Members         | Read-only           | 
 
 ### GitLab
 - Follow: [https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html#create-a-personal-access-token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html#create-a-personal-access-token)
diff --git a/runatlantis.io/docs/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md
index 0c9a902afb..d4a77f9f56 100644
--- a/runatlantis.io/docs/server-side-repo-config.md
+++ b/runatlantis.io/docs/server-side-repo-config.md
@@ -521,7 +521,8 @@ If you set a workflow with the key `default`, it will override this.
 ### Owners
 | Key         | Type              | Default | Required   | Description                                             |
 |-------------|-------------------|---------|------------|---------------------------------------------------------|
-| users       | []string          | none    | yes        | list of github users that can approve failing policies  |
+| users       | []string          | none    | no         | list of github users that can approve failing policies  |
+| teams       | []string          | none    | no         | list of github teams that can approve failing policies  |
 
 ### PolicySet
 
diff --git a/server/controllers/github_app_controller.go b/server/controllers/github_app_controller.go
index 14753a38f6..06a1604136 100644
--- a/server/controllers/github_app_controller.go
+++ b/server/controllers/github_app_controller.go
@@ -121,6 +121,7 @@ func (g *GithubAppController) New(w http.ResponseWriter, r *http.Request) {
 			"repository_hooks": "write",
 			"statuses":         "write",
 			"administration":   "read",
+			"members":          "read",
 		},
 	}
 
diff --git a/server/core/config/raw/policies.go b/server/core/config/raw/policies.go
index 33fb78ee18..270e314a41 100644
--- a/server/core/config/raw/policies.go
+++ b/server/core/config/raw/policies.go
@@ -40,6 +40,7 @@ func (p PolicySets) ToValid() valid.PolicySets {
 
 type PolicyOwners struct {
 	Users []string `yaml:"users,omitempty" json:"users,omitempty"`
+	Teams []string `yaml:"teams,omitempty" json:"teams,omitempty"`
 }
 
 func (o PolicyOwners) ToValid() valid.PolicyOwners {
@@ -48,6 +49,10 @@ func (o PolicyOwners) ToValid() valid.PolicyOwners {
 	if len(o.Users) > 0 {
 		policyOwners.Users = o.Users
 	}
+
+	if len(o.Teams) > 0 {
+		policyOwners.Teams = o.Teams
+	}
 	return policyOwners
 }
 
diff --git a/server/core/config/valid/policies.go b/server/core/config/valid/policies.go
index 7eafd6185c..48d166cea2 100644
--- a/server/core/config/valid/policies.go
+++ b/server/core/config/valid/policies.go
@@ -22,6 +22,7 @@ type PolicySets struct {
 
 type PolicyOwners struct {
 	Users []string
+	Teams []string
 }
 
 type PolicySet struct {
@@ -35,12 +36,24 @@ func (p *PolicySets) HasPolicies() bool {
 	return len(p.PolicySets) > 0
 }
 
-func (p *PolicySets) IsOwner(username string) bool {
+func (p *PolicySets) HasTeamOwners() bool {
+	return len(p.Owners.Teams) > 0
+}
+
+func (p *PolicySets) IsOwner(username string, userTeams []string) bool {
 	for _, uname := range p.Owners.Users {
 		if strings.EqualFold(uname, username) {
 			return true
 		}
 	}
 
+	for _, orgTeamName := range p.Owners.Teams {
+		for _, userTeamName := range userTeams {
+			if strings.EqualFold(orgTeamName, userTeamName) {
+				return true
+			}
+		}
+	}
+
 	return false
 }
diff --git a/server/events/approve_policies_command_runner.go b/server/events/approve_policies_command_runner.go
index 7e9a56e1a9..1d1056681b 100644
--- a/server/events/approve_policies_command_runner.go
+++ b/server/events/approve_policies_command_runner.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/events/models"
+	"github.com/runatlantis/atlantis/server/events/vcs"
 )
 
 func NewApprovePoliciesCommandRunner(
@@ -15,6 +16,7 @@ func NewApprovePoliciesCommandRunner(
 	dbUpdater *DBUpdater,
 	SilenceNoProjects bool,
 	silenceVCSStatusNoProjects bool,
+	vcsClient vcs.Client,
 ) *ApprovePoliciesCommandRunner {
 	return &ApprovePoliciesCommandRunner{
 		commitStatusUpdater:        commitStatusUpdater,
@@ -24,6 +26,7 @@ func NewApprovePoliciesCommandRunner(
 		dbUpdater:                  dbUpdater,
 		SilenceNoProjects:          SilenceNoProjects,
 		silenceVCSStatusNoProjects: silenceVCSStatusNoProjects,
+		vcsClient:                  vcsClient,
 	}
 }
 
@@ -37,6 +40,7 @@ type ApprovePoliciesCommandRunner struct {
 	// are found
 	SilenceNoProjects          bool
 	silenceVCSStatusNoProjects bool
+	vcsClient                  vcs.Client
 }
 
 func (a *ApprovePoliciesCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
@@ -91,9 +95,23 @@ func (a *ApprovePoliciesCommandRunner) buildApprovePolicyCommandResults(ctx *com
 	// Check if vcs user is in the owner list of the PolicySets. All projects
 	// share the same Owners list at this time so no reason to iterate over each
 	// project.
-	if len(prjCmds) > 0 && !prjCmds[0].PolicySets.IsOwner(ctx.User.Username) {
-		result.Error = fmt.Errorf("contact policy owners to approve failing policies")
-		return
+	if len(prjCmds) > 0 {
+		teams := []string{}
+
+		// Only query the users team membership if any teams have been configured as owners.
+		if prjCmds[0].PolicySets.HasTeamOwners() {
+			userTeams, err := a.vcsClient.GetTeamNamesForUser(ctx.Pull.BaseRepo, ctx.User)
+			if err != nil {
+				ctx.Log.Err("unable to get team membership for user: %s", err)
+				return
+			}
+			teams = append(teams, userTeams...)
+		}
+
+		if !prjCmds[0].PolicySets.IsOwner(ctx.User.Username, teams) {
+			result.Error = fmt.Errorf("contact policy owners to approve failing policies")
+			return
+		}
 	}
 
 	var prjResults []command.ProjectResult
diff --git a/server/server.go b/server/server.go
index 5b6158e73e..0a87d38070 100644
--- a/server/server.go
+++ b/server/server.go
@@ -714,6 +714,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		dbUpdater,
 		userConfig.SilenceNoProjects,
 		userConfig.SilenceVCSStatusNoPlans,
+		vcsClient,
 	)
 
 	unlockCommandRunner := events.NewUnlockCommandRunner(


Test patch:
diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go
index 62b59ef653..3371a1c124 100644
--- a/server/controllers/events/events_controller_e2e_test.go
+++ b/server/controllers/events/events_controller_e2e_test.go
@@ -1222,6 +1222,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers
 		dbUpdater,
 		silenceNoProjects,
 		false,
+		e2eVCSClient,
 	)
 
 	unlockCommandRunner := events.NewUnlockCommandRunner(
diff --git a/server/core/config/raw/policies_test.go b/server/core/config/raw/policies_test.go
index 53b599d1b2..7987d7bf57 100644
--- a/server/core/config/raw/policies_test.go
+++ b/server/core/config/raw/policies_test.go
@@ -180,6 +180,9 @@ func TestPolicySets_ToValid(t *testing.T) {
 					Users: []string{
 						"test",
 					},
+					Teams: []string{
+						"testteam",
+					},
 				},
 				PolicySets: []raw.PolicySet{
 					{
@@ -199,6 +202,7 @@ func TestPolicySets_ToValid(t *testing.T) {
 				Version: version,
 				Owners: valid.PolicyOwners{
 					Users: []string{"test"},
+					Teams: []string{"testteam"},
 				},
 				PolicySets: []valid.PolicySet{
 					{
diff --git a/server/events/approve_policies_command_runner_test.go b/server/events/approve_policies_command_runner_test.go
index 8f7fa06dda..2fc5268922 100644
--- a/server/events/approve_policies_command_runner_test.go
+++ b/server/events/approve_policies_command_runner_test.go
@@ -20,16 +20,41 @@ func TestApproveCommandRunner_IsOwner(t *testing.T) {
 	cases := []struct {
 		Description string
 		OwnerUsers  []string
+		OwnerTeams  []string // Teams configured as owners
+		UserTeams   []string // Teams the user is a member of
 		ExpComment  string
 	}{
 		{
 			Description: "When user is not an owner, approval fails",
 			OwnerUsers:  []string{},
+			OwnerTeams:  []string{},
 			ExpComment:  "**Approve Policies Error**\n```\ncontact policy owners to approve failing policies\n```\n",
 		},
 		{
 			Description: "When user is an owner, approval succeeds",
 			OwnerUsers:  []string{fixtures.User.Username},
+			OwnerTeams:  []string{},
+			ExpComment:  "Approved Policies for 1 projects:\n\n1. dir: `` workspace: ``\n\n\n",
+		},
+		{
+			Description: "When user is an owner via team membership, approval succeeds",
+			OwnerUsers:  []string{},
+			OwnerTeams:  []string{"SomeTeam"},
+			UserTeams:   []string{"SomeTeam"},
+			ExpComment:  "Approved Policies for 1 projects:\n\n1. dir: `` workspace: ``\n\n\n",
+		},
+		{
+			Description: "When user belongs to a team not configured as a owner, approval fails",
+			OwnerUsers:  []string{},
+			OwnerTeams:  []string{"SomeTeam"},
+			UserTeams:   []string{"SomeOtherTeam}"},
+			ExpComment:  "**Approve Policies Error**\n```\ncontact policy owners to approve failing policies\n```\n",
+		},
+		{
+			Description: "When user is an owner but not a team member, approval succeeds",
+			OwnerUsers:  []string{fixtures.User.Username},
+			OwnerTeams:  []string{"SomeTeam"},
+			UserTeams:   []string{"SomeOtherTeam"},
 			ExpComment:  "Approved Policies for 1 projects:\n\n1. dir: `` workspace: ``\n\n\n",
 		},
 	}
@@ -57,10 +82,12 @@ func TestApproveCommandRunner_IsOwner(t *testing.T) {
 					PolicySets: valid.PolicySets{
 						Owners: valid.PolicyOwners{
 							Users: c.OwnerUsers,
+							Teams: c.OwnerTeams,
 						},
 					},
 				},
 			}, nil)
+			When(vcsClient.GetTeamNamesForUser(fixtures.GithubRepo, fixtures.User)).ThenReturn(c.UserTeams, nil)
 
 			approvePoliciesCommandRunner.Run(ctx, &events.CommentCommand{Name: command.ApprovePolicies})
 
diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go
index c2c830c6a1..62019b2bcd 100644
--- a/server/events/command_runner_test.go
+++ b/server/events/command_runner_test.go
@@ -182,6 +182,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock
 		dbUpdater,
 		testConfig.SilenceNoProjects,
 		false,
+		vcsClient,
 	)
 
 	unlockCommandRunner = events.NewUnlockCommandRunner(