Browse Source

gitlab: support for CI/CD group variables (#1692)

* gitlab: support for ci/cd group variables

Signed-off-by: Dominik Zeiger <dominik@zeiger.biz>

* gitlab: support for ci/cd group variables (automatically discover project groups)

Signed-off-by: Dominik Zeiger <dominik@zeiger.biz>

* gitlab: support for ci/cd group variables (documentation)

Signed-off-by: Dominik Zeiger <dominik@zeiger.biz>

Signed-off-by: Dominik Zeiger <dominik@zeiger.biz>
Dominik Zeiger 3 years ago
parent
commit
f38f40a2b4

+ 6 - 0
apis/externalsecrets/v1beta1/secretstore_gitlab_types.go

@@ -29,6 +29,12 @@ type GitlabProvider struct {
 	// ProjectID specifies a project where secrets are located.
 	ProjectID string `json:"projectID,omitempty"`
 
+	// InheritFromGroups specifies whether parent groups should be discovered and checked for secrets.
+	InheritFromGroups bool `json:"inheritFromGroups,omitempty"`
+
+	// GroupIDs specify, which gitlab groups to pull secrets from. Group secrets are read from left to right followed by the project variables.
+	GroupIDs []string `json:"groupIDs,omitempty"`
+
 	// Environment environment_scope of gitlab CI/CD variables (Please see https://docs.gitlab.com/ee/ci/environments/#create-a-static-environment on how to create environments)
 	Environment string `json:"environment,omitempty"`
 }

+ 5 - 0
apis/externalsecrets/v1beta1/zz_generated.deepcopy.go

@@ -1184,6 +1184,11 @@ func (in *GitlabAuth) DeepCopy() *GitlabAuth {
 func (in *GitlabProvider) DeepCopyInto(out *GitlabProvider) {
 	*out = *in
 	in.Auth.DeepCopyInto(&out.Auth)
+	if in.GroupIDs != nil {
+		in, out := &in.GroupIDs, &out.GroupIDs
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitlabProvider.

+ 11 - 0
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -2274,6 +2274,17 @@ spec:
                           variables (Please see https://docs.gitlab.com/ee/ci/environments/#create-a-static-environment
                           on how to create environments)
                         type: string
+                      groupIDs:
+                        description: GroupIDs specify, which gitlab groups to pull
+                          secrets from. Group secrets are read from left to right
+                          followed by the project variables.
+                        items:
+                          type: string
+                        type: array
+                      inheritFromGroups:
+                        description: InheritFromGroups specifies whether parent groups
+                          should be discovered and checked for secrets.
+                        type: boolean
                       projectID:
                         description: ProjectID specifies a project where secrets are
                           located.

+ 11 - 0
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -2274,6 +2274,17 @@ spec:
                           variables (Please see https://docs.gitlab.com/ee/ci/environments/#create-a-static-environment
                           on how to create environments)
                         type: string
+                      groupIDs:
+                        description: GroupIDs specify, which gitlab groups to pull
+                          secrets from. Group secrets are read from left to right
+                          followed by the project variables.
+                        items:
+                          type: string
+                        type: array
+                      inheritFromGroups:
+                        description: InheritFromGroups specifies whether parent groups
+                          should be discovered and checked for secrets.
+                        type: boolean
                       projectID:
                         description: ProjectID specifies a project where secrets are
                           located.

+ 16 - 0
deploy/crds/bundle.yaml

@@ -2094,6 +2094,14 @@ spec:
                         environment:
                           description: Environment environment_scope of gitlab CI/CD variables (Please see https://docs.gitlab.com/ee/ci/environments/#create-a-static-environment on how to create environments)
                           type: string
+                        groupIDs:
+                          description: GroupIDs specify, which gitlab groups to pull secrets from. Group secrets are read from left to right followed by the project variables.
+                          items:
+                            type: string
+                          type: array
+                        inheritFromGroups:
+                          description: InheritFromGroups specifies whether parent groups should be discovered and checked for secrets.
+                          type: boolean
                         projectID:
                           description: ProjectID specifies a project where secrets are located.
                           type: string
@@ -5145,6 +5153,14 @@ spec:
                         environment:
                           description: Environment environment_scope of gitlab CI/CD variables (Please see https://docs.gitlab.com/ee/ci/environments/#create-a-static-environment on how to create environments)
                           type: string
+                        groupIDs:
+                          description: GroupIDs specify, which gitlab groups to pull secrets from. Group secrets are read from left to right followed by the project variables.
+                          items:
+                            type: string
+                          type: array
+                        inheritFromGroups:
+                          description: InheritFromGroups specifies whether parent groups should be discovered and checked for secrets.
+                          type: boolean
                         projectID:
                           description: ProjectID specifies a project where secrets are located.
                           type: string

+ 3 - 4
docs/provider/gitlab-project-variables.md

@@ -1,10 +1,10 @@
-## Gitlab Project Variables
+## Gitlab Project and Group Variables
 
-External Secrets Operator integrates with [Gitlab API](https://docs.gitlab.com/ee/api/project_level_variables.html) to sync Gitlab project variables to secrets held on the Kubernetes cluster.
+External Secrets Operator integrates with Gitlab to sync [Gitlab Project Variables API](https://docs.gitlab.com/ee/api/project_level_variables.html) and/or [Gitlab Group Variables API](https://docs.gitlab.com/ee/api/group_level_variables.html) to secrets held on the Kubernetes cluster.
 
 ### Authentication
 
-The API requires an access token and project ID. To create a new access token, go to your user settings and select 'access tokens'. Give your token a name, expiration date, and select the permissions required (Note 'api' is required).
+The API requires an access token and project ID/groupIDs. To create a new access token, go to your user settings and select 'access tokens'. Give your token a name, expiration date, and select the permissions required (Note 'api' is required).
 
 ![token-details](../pictures/screenshot_gitlab_token.png)
 
@@ -12,7 +12,6 @@ Click 'Create personal access token', and your token will be generated and displ
 ![token-created](../pictures/screenshot_gitlab_token_created.png)
 
 
-
 ### Access Token secret
 
 Create a secret containing your access token:

+ 2 - 0
docs/snippets/gitlab-secret-store.yaml

@@ -13,4 +13,6 @@ spec:
             name: gitlab-secret
             key: token
       projectID: "**project ID goes here**"
+      groupIDs: "**groupID(s) go here**"
+      inheritFromGroups: "**automatically looks for variables in parent groups**"
       environment: "**environment scope goes here**"

+ 1 - 1
hack/api-docs/mkdocs.yml

@@ -86,7 +86,7 @@ nav:
     - HashiCorp Vault: provider/hashicorp-vault.md
     - Yandex Certificate Manager: provider/yandex-certificate-manager.md
     - Yandex Lockbox: provider/yandex-lockbox.md
-    - Gitlab Project Variables: provider/gitlab-project-variables.md
+    - Gitlab Project Variables: provider/gitlab-variables.md
     - Oracle Vault: provider/oracle-vault.md
     - 1Password Secrets Automation: provider/1password-automation.md
     - Webhook: provider/webhook.md

+ 45 - 4
pkg/provider/gitlab/fake/fake.go

@@ -17,20 +17,36 @@ import (
 	gitlab "github.com/xanzy/go-gitlab"
 )
 
-type GitlabMockClient struct {
+type GitlabMockProjectsClient struct {
+	listProjectsGroups func(pid interface{}, opt *gitlab.ListProjectGroupOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectGroup, *gitlab.Response, error)
+}
+
+func (mc *GitlabMockProjectsClient) ListProjectsGroups(pid interface{}, opt *gitlab.ListProjectGroupOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectGroup, *gitlab.Response, error) {
+	return mc.listProjectsGroups(pid, opt, nil)
+}
+
+func (mc *GitlabMockProjectsClient) WithValue(output []*gitlab.ProjectGroup, response *gitlab.Response, err error) {
+	if mc != nil {
+		mc.listProjectsGroups = func(pid interface{}, opt *gitlab.ListProjectGroupOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectGroup, *gitlab.Response, error) {
+			return output, response, err
+		}
+	}
+}
+
+type GitlabMockProjectVariablesClient struct {
 	getVariable   func(pid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
 	listVariables func(pid interface{}, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error)
 }
 
-func (mc *GitlabMockClient) GetVariable(pid interface{}, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (mc *GitlabMockProjectVariablesClient) GetVariable(pid interface{}, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return mc.getVariable(pid, key, nil)
 }
 
-func (mc *GitlabMockClient) ListVariables(pid interface{}, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (mc *GitlabMockProjectVariablesClient) ListVariables(pid interface{}, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return mc.listVariables(pid)
 }
 
-func (mc *GitlabMockClient) WithValue(projectIDinput, envInput, keyInput string, output *gitlab.ProjectVariable, response *gitlab.Response, err error) {
+func (mc *GitlabMockProjectVariablesClient) WithValue(envInput, keyInput string, output *gitlab.ProjectVariable, response *gitlab.Response, err error) {
 	if mc != nil {
 		mc.getVariable = func(pid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
 			// type secretmanagerpb.AccessSecretVersionRequest contains unexported fields
@@ -46,3 +62,28 @@ func (mc *GitlabMockClient) WithValue(projectIDinput, envInput, keyInput string,
 		}
 	}
 }
+
+type GitlabMockGroupVariablesClient struct {
+	getVariable   func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	listVariables func(gid interface{}, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
+}
+
+func (mc *GitlabMockGroupVariablesClient) GetVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+	return mc.getVariable(gid, key, nil)
+}
+
+func (mc *GitlabMockGroupVariablesClient) ListVariables(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error) {
+	return mc.listVariables(gid)
+}
+
+func (mc *GitlabMockGroupVariablesClient) WithValue(output *gitlab.GroupVariable, response *gitlab.Response, err error) {
+	if mc != nil {
+		mc.getVariable = func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+			return output, response, err
+		}
+
+		mc.listVariables = func(gid interface{}, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error) {
+			return []*gitlab.GroupVariable{output}, response, err
+		}
+	}
+}

+ 164 - 42
pkg/provider/gitlab/gitlab.go

@@ -18,12 +18,15 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"sort"
+	"strconv"
 	"strings"
 
 	"github.com/tidwall/gjson"
 	gitlab "github.com/xanzy/go-gitlab"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
 	kclient "sigs.k8s.io/controller-runtime/pkg/client"
 
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
@@ -36,8 +39,9 @@ const (
 	errInvalidClusterStoreMissingSAKNamespace = "invalid clusterStore missing SAK namespace"
 	errFetchSAKSecret                         = "couldn't find secret on cluster: %w"
 	errMissingSAK                             = "missing credentials while setting auth"
-	errList                                   = "could not verify if the client is valid: %w"
-	errAuth                                   = "client is not allowed to get secrets"
+	errList                                   = "could not verify whether the gilabClient is valid: %w"
+	errProjectAuth                            = "gitlabClient is not allowed to get secrets for project id [%s]"
+	errGroupAuth                              = "gitlabClient is not allowed to get secrets for group id [%s]"
 	errUninitializedGitlabProvider            = "provider gitlab is not initialized"
 	errNameNotDefined                         = "'find.name' is mandatory"
 	errTagsNotImplemented                     = "'find.tags' is not currently supported by Gitlab provider"
@@ -49,20 +53,33 @@ const (
 var _ esv1beta1.SecretsClient = &Gitlab{}
 var _ esv1beta1.Provider = &Gitlab{}
 
-type Client interface {
+type ProjectsClient interface {
+	ListProjectsGroups(pid interface{}, opt *gitlab.ListProjectGroupOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectGroup, *gitlab.Response, error)
+}
+
+type ProjectVariablesClient interface {
 	GetVariable(pid interface{}, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
 	ListVariables(pid interface{}, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error)
 }
 
-// Gitlab Provider struct with reference to a GitLab client and a projectID.
+type GroupVariablesClient interface {
+	GetVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	ListVariables(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
+}
+
+// Gitlab Provider struct with reference to GitLab clients, a projectID and groupIDs.
 type Gitlab struct {
-	client      Client
-	url         string
-	projectID   interface{}
-	environment string
+	projectsClient         ProjectsClient
+	projectVariablesClient ProjectVariablesClient
+	groupVariablesClient   GroupVariablesClient
+	url                    string
+	projectID              string
+	inheritFromGroups      bool
+	groupIDs               []string
+	environment            string
 }
 
-// Client for interacting with kubernetes cluster...?
+// gClient for interacting with kubernetes cluster...?
 type gClient struct {
 	kube        kclient.Client
 	store       *esv1beta1.GitlabProvider
@@ -71,6 +88,14 @@ type gClient struct {
 	credentials []byte
 }
 
+type ProjectGroupPathSorter []*gitlab.ProjectGroup
+
+func (a ProjectGroupPathSorter) Len() int           { return len(a) }
+func (a ProjectGroupPathSorter) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
+func (a ProjectGroupPathSorter) Less(i, j int) bool { return len(a[i].FullPath) < len(a[j].FullPath) }
+
+var log = ctrl.Log.WithName("provider").WithName("gitlab")
+
 func init() {
 	esv1beta1.Register(&Gitlab{}, &esv1beta1.SecretStoreProvider{
 		Gitlab: &esv1beta1.GitlabProvider{},
@@ -105,9 +130,6 @@ func (c *gClient) setAuth(ctx context.Context) error {
 	if c.credentials == nil || len(c.credentials) == 0 {
 		return fmt.Errorf(errMissingSAK)
 	}
-	// I don't know where ProjectID is being set
-	// This line SHOULD set it, but instead just breaks everything :)
-	// c.store.ProjectID = string(credentialsSecret.Data[c.store.ProjectID])
 	return nil
 }
 
@@ -116,7 +138,7 @@ func NewGitlabProvider() *Gitlab {
 	return &Gitlab{}
 }
 
-// Method on Gitlab Provider to set up client with credentials, populate projectID and environment.
+// Method on Gitlab Provider to set up projectVariablesClient with credentials, populate projectID and environment.
 func (g *Gitlab) NewClient(ctx context.Context, store esv1beta1.GenericStore, kube kclient.Client, namespace string) (esv1beta1.SecretsClient, error) {
 	storeSpec := store.GetSpec()
 	if storeSpec == nil || storeSpec.Provider == nil || storeSpec.Provider.Gitlab == nil {
@@ -137,22 +159,27 @@ func (g *Gitlab) NewClient(ctx context.Context, store esv1beta1.GenericStore, ku
 
 	var err error
 
-	// Create client options
+	// Create projectVariablesClient options
 	var opts []gitlab.ClientOptionFunc
 	if cliStore.store.URL != "" {
 		opts = append(opts, gitlab.WithBaseURL(cliStore.store.URL))
 	}
+
 	// ClientOptionFunc from the gitlab package can be mapped with the CRD
 	// in a similar way to extend functionality of the provider
 
-	// Create a new Gitlab client using credentials and options
+	// Create a new Gitlab projectVariablesClient using credentials and options
 	gitlabClient, err := gitlab.NewClient(string(cliStore.credentials), opts...)
 	if err != nil {
 		return nil, err
 	}
 
-	g.client = gitlabClient.ProjectVariables
+	g.projectsClient = gitlabClient.Projects
+	g.projectVariablesClient = gitlabClient.ProjectVariables
+	g.groupVariablesClient = gitlabClient.GroupVariables
 	g.projectID = cliStore.store.ProjectID
+	g.inheritFromGroups = cliStore.store.InheritFromGroups
+	g.groupIDs = cliStore.store.GroupIDs
 	g.environment = cliStore.store.Environment
 	g.url = cliStore.store.URL
 
@@ -161,7 +188,7 @@ func (g *Gitlab) NewClient(ctx context.Context, store esv1beta1.GenericStore, ku
 
 // GetAllSecrets syncs all gitlab project variables into a single Kubernetes Secret.
 func (g *Gitlab) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecretFind) (map[string][]byte, error) {
-	if utils.IsNil(g.client) {
+	if utils.IsNil(g.projectVariablesClient) {
 		return nil, fmt.Errorf(errUninitializedGitlabProvider)
 	}
 	if ref.Tags != nil {
@@ -174,11 +201,6 @@ func (g *Gitlab) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecret
 		return nil, fmt.Errorf(errNameNotDefined)
 	}
 
-	allData, _, err := g.client.ListVariables(g.projectID, nil)
-	if err != nil {
-		return nil, err
-	}
-
 	var matcher *find.Matcher
 	if ref.Name != nil {
 		m, err := find.New(*ref.Name)
@@ -187,9 +209,36 @@ func (g *Gitlab) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecret
 		}
 		matcher = m
 	}
+
+	err := g.ResolveGroupIds()
+	if err != nil {
+		return nil, err
+	}
+
 	secretData := make(map[string][]byte)
-	for _, data := range allData {
-		matching, key := matchesFilter(g.environment, data, matcher)
+	for _, groupID := range g.groupIDs {
+		var groupVars []*gitlab.GroupVariable
+		groupVars, _, err := g.groupVariablesClient.ListVariables(groupID, nil)
+		if err != nil {
+			return nil, err
+		}
+		for _, data := range groupVars {
+			matching, key := matchesFilter(g.environment, data.EnvironmentScope, data.Key, matcher)
+			if !matching {
+				continue
+			}
+			secretData[key] = []byte(data.Value)
+		}
+	}
+
+	var projectData []*gitlab.ProjectVariable
+	projectData, _, err = g.projectVariablesClient.ListVariables(g.projectID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, data := range projectData {
+		matching, key := matchesFilter(g.environment, data.EnvironmentScope, data.Key, matcher)
 		if !matching {
 			continue
 		}
@@ -200,9 +249,10 @@ func (g *Gitlab) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecret
 }
 
 func (g *Gitlab) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
-	if utils.IsNil(g.client) {
+	if utils.IsNil(g.projectVariablesClient) || utils.IsNil(g.groupVariablesClient) {
 		return nil, fmt.Errorf(errUninitializedGitlabProvider)
 	}
+
 	// Need to replace hyphens with underscores to work with Gitlab API
 	ref.Key = strings.ReplaceAll(ref.Key, "-", "_")
 	// Retrieves a gitlab variable in the form
@@ -214,26 +264,58 @@ func (g *Gitlab) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretData
 	// 	"masked": true,
 	// 	"environment_scope": "*"
 	// }
-
 	var vopts *gitlab.GetProjectVariableOptions
 	if g.environment != "" {
 		vopts = &gitlab.GetProjectVariableOptions{Filter: &gitlab.VariableFilter{EnvironmentScope: g.environment}}
 	}
-	data, _, err := g.client.GetVariable(g.projectID, ref.Key, vopts)
+
+	data, resp, err := g.projectVariablesClient.GetVariable(g.projectID, ref.Key, vopts)
+	if resp.StatusCode >= 400 && resp.StatusCode != 404 && err != nil {
+		return nil, err
+	}
+
+	err = g.ResolveGroupIds()
 	if err != nil {
 		return nil, err
 	}
 
+	var result []byte
+	if resp.StatusCode < 300 {
+		result, err = extractVariable(ref, data.Value)
+	}
+
+	for i := len(g.groupIDs) - 1; i >= 0; i-- {
+		groupID := g.groupIDs[i]
+		if result != nil {
+			return result, nil
+		}
+
+		groupVar, resp, err := g.groupVariablesClient.GetVariable(groupID, ref.Key, nil)
+		if resp.StatusCode >= 400 && resp.StatusCode != 404 && err != nil {
+			return nil, err
+		}
+		if resp.StatusCode < 300 {
+			result, _ = extractVariable(ref, groupVar.Value)
+		}
+	}
+
+	if result != nil {
+		return result, nil
+	}
+	return nil, err
+}
+
+func extractVariable(ref esv1beta1.ExternalSecretDataRemoteRef, value string) ([]byte, error) {
 	if ref.Property == "" {
-		if data.Value != "" {
-			return []byte(data.Value), nil
+		if value != "" {
+			return []byte(value), nil
 		}
 		return nil, fmt.Errorf("invalid secret received. no secret string for key: %s", ref.Key)
 	}
 
 	var payload string
-	if data.Value != "" {
-		payload = data.Value
+	if value != "" {
+		payload = value
 	}
 
 	val := gjson.Get(payload, ref.Property)
@@ -265,15 +347,14 @@ func (g *Gitlab) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretD
 	return secretData, nil
 }
 
-func matchesFilter(environment string, data *gitlab.ProjectVariable, matcher *find.Matcher) (bool, string) {
+func matchesFilter(environment, varEnvironment, key string, matcher *find.Matcher) (bool, string) {
 	if environment != "" && environment != "*" {
 		// as of now gitlab does not support filtering of EnvironmentScope through the api call
-		if data.EnvironmentScope != environment {
+		if varEnvironment != environment {
 			return false, ""
 		}
 	}
 
-	key := data.Key
 	if key == "" || (matcher != nil && !matcher.MatchName(key)) {
 		return false, ""
 	}
@@ -285,17 +366,53 @@ func (g *Gitlab) Close(ctx context.Context) error {
 	return nil
 }
 
-// Validate will use the gitlab client to validate the gitlab provider using the ListVariable call to ensure get permissions without needing a specific key.
+// Validate will use the gitlab projectVariablesClient/groupVariablesClient to validate the gitlab provider using the ListVariable call to ensure get permissions without needing a specific key.
 func (g *Gitlab) Validate() (esv1beta1.ValidationResult, error) {
-	_, resp, err := g.client.ListVariables(g.projectID, nil)
-	if err != nil {
-		return esv1beta1.ValidationResultError, fmt.Errorf(errList, err)
-	} else if resp == nil || resp.StatusCode != http.StatusOK {
-		return esv1beta1.ValidationResultError, fmt.Errorf(errAuth)
+	if g.projectID != "" {
+		_, resp, err := g.projectVariablesClient.ListVariables(g.projectID, nil)
+		if err != nil {
+			return esv1beta1.ValidationResultError, fmt.Errorf(errList, err)
+		} else if resp == nil || resp.StatusCode != http.StatusOK {
+			return esv1beta1.ValidationResultError, fmt.Errorf(errProjectAuth, g.projectID)
+		}
+
+		err = g.ResolveGroupIds()
+		if err != nil {
+			return esv1beta1.ValidationResultError, fmt.Errorf(errList, err)
+		}
+		log.V(1).Info("discovered project groups", "name", g.groupIDs)
+	}
+
+	if len(g.groupIDs) > 0 {
+		for _, groupID := range g.groupIDs {
+			_, resp, err := g.groupVariablesClient.ListVariables(groupID, nil)
+			if err != nil {
+				return esv1beta1.ValidationResultError, fmt.Errorf(errList, err)
+			} else if resp == nil || resp.StatusCode != http.StatusOK {
+				return esv1beta1.ValidationResultError, fmt.Errorf(errGroupAuth, groupID)
+			}
+		}
 	}
+
 	return esv1beta1.ValidationResultReady, nil
 }
 
+func (g *Gitlab) ResolveGroupIds() error {
+	if g.inheritFromGroups {
+		projectGroups, resp, err := g.projectsClient.ListProjectsGroups(g.projectID, nil)
+		if resp.StatusCode >= 400 && err != nil {
+			return err
+		}
+		sort.Sort(ProjectGroupPathSorter(projectGroups))
+		discoveredIds := make([]string, len(projectGroups))
+		for i, group := range projectGroups {
+			discoveredIds[i] = strconv.Itoa(group.ID)
+		}
+		g.groupIDs = discoveredIds
+	}
+	return nil
+}
+
 func (g *Gitlab) ValidateStore(store esv1beta1.GenericStore) error {
 	storeSpec := store.GetSpec()
 	gitlabSpec := storeSpec.Provider.Gitlab
@@ -305,8 +422,12 @@ func (g *Gitlab) ValidateStore(store esv1beta1.GenericStore) error {
 		return err
 	}
 
-	if gitlabSpec.ProjectID == "" {
-		return fmt.Errorf("projectID cannot be empty")
+	if gitlabSpec.ProjectID == "" && len(gitlabSpec.GroupIDs) == 0 {
+		return fmt.Errorf("projectID and groupIDs must not both be empty")
+	}
+
+	if gitlabSpec.InheritFromGroups && len(gitlabSpec.GroupIDs) > 0 {
+		return fmt.Errorf("defining groupIDs and inheritFromGroups = true is not allowed")
 	}
 
 	if accessToken.Key == "" {
@@ -316,5 +437,6 @@ func (g *Gitlab) ValidateStore(store esv1beta1.GenericStore) error {
 	if accessToken.Name == "" {
 		return fmt.Errorf("accessToken.name cannot be empty")
 	}
+
 	return nil
 }

+ 270 - 50
pkg/provider/gitlab/gitlab_test.go

@@ -41,20 +41,32 @@ const (
 	username              = "user-name"
 	userkey               = "user-key"
 	environment           = "prod"
+	projectvalue          = "projectvalue"
+	groupvalue            = "groupvalue"
+	groupid               = "groupId"
 	defaultErrorMessage   = "[%d] unexpected error: %s, expected: '%s'"
 	errMissingCredentials = "credentials are empty"
+	findTestPrefix        = "test.*"
 )
 
 type secretManagerTestCase struct {
-	mockClient               *fakegitlab.GitlabMockClient
+	mockProjectsClient       *fakegitlab.GitlabMockProjectsClient
+	mockProjectVarClient     *fakegitlab.GitlabMockProjectVariablesClient
+	mockGroupVarClient       *fakegitlab.GitlabMockGroupVariablesClient
 	apiInputProjectID        string
 	apiInputKey              string
 	apiInputEnv              string
-	apiOutput                *gitlab.ProjectVariable
-	apiResponse              *gitlab.Response
+	projectAPIOutput         *gitlab.ProjectVariable
+	projectAPIResponse       *gitlab.Response
+	projectGroupsAPIOutput   []*gitlab.ProjectGroup
+	projectGroupsAPIResponse *gitlab.Response
+	groupAPIOutput           *gitlab.GroupVariable
+	groupAPIResponse         *gitlab.Response
 	ref                      *esv1beta1.ExternalSecretDataRemoteRef
 	refFind                  *esv1beta1.ExternalSecretFind
-	projectID                *string
+	projectID                string
+	groupIDs                 []string
+	inheritFromGroups        bool
 	apiErr                   error
 	expectError              string
 	expectedSecret           string
@@ -65,22 +77,30 @@ type secretManagerTestCase struct {
 
 func makeValidSecretManagerTestCase() *secretManagerTestCase {
 	smtc := secretManagerTestCase{
-		mockClient:               &fakegitlab.GitlabMockClient{},
+		mockProjectsClient:       &fakegitlab.GitlabMockProjectsClient{},
+		mockProjectVarClient:     &fakegitlab.GitlabMockProjectVariablesClient{},
+		mockGroupVarClient:       &fakegitlab.GitlabMockGroupVariablesClient{},
 		apiInputProjectID:        makeValidAPIInputProjectID(),
 		apiInputKey:              makeValidAPIInputKey(),
 		apiInputEnv:              makeValidEnvironment(),
 		ref:                      makeValidRef(),
 		refFind:                  makeValidFindRef(),
-		projectID:                nil,
-		apiOutput:                makeValidAPIOutput(),
-		apiResponse:              makeValidAPIResponse(),
+		projectID:                makeValidProjectID(),
+		groupIDs:                 makeEmptyGroupIds(),
+		projectAPIOutput:         makeValidProjectAPIOutput(),
+		projectAPIResponse:       makeValidProjectAPIResponse(),
+		projectGroupsAPIOutput:   makeValidProjectGroupsAPIOutput(),
+		projectGroupsAPIResponse: makeValidProjectGroupsAPIResponse(),
+		groupAPIOutput:           makeValidGroupAPIOutput(),
+		groupAPIResponse:         makeValidGroupAPIResponse(),
 		apiErr:                   nil,
 		expectError:              "",
 		expectedSecret:           "",
 		expectedValidationResult: esv1beta1.ValidationResultReady,
 		expectedData:             map[string][]byte{},
 	}
-	smtc.mockClient.WithValue(smtc.apiInputProjectID, smtc.apiInputEnv, smtc.apiInputKey, smtc.apiOutput, smtc.apiResponse, smtc.apiErr)
+	smtc.mockProjectVarClient.WithValue(smtc.apiInputEnv, smtc.apiInputKey, smtc.projectAPIOutput, smtc.projectAPIResponse, smtc.apiErr)
+	smtc.mockGroupVarClient.WithValue(smtc.groupAPIOutput, smtc.groupAPIResponse, smtc.apiErr)
 	return &smtc
 }
 
@@ -95,6 +115,14 @@ func makeValidFindRef() *esv1beta1.ExternalSecretFind {
 	return &esv1beta1.ExternalSecretFind{}
 }
 
+func makeValidProjectID() string {
+	return "projectId"
+}
+
+func makeEmptyGroupIds() []string {
+	return []string{}
+}
+
 func makeFindName(regexp string) *esv1beta1.FindName {
 	return &esv1beta1.FindName{
 		RegExp: regexp,
@@ -110,10 +138,26 @@ func makeValidAPIInputKey() string {
 }
 
 func makeValidEnvironment() string {
-	return "prod"
+	return environment
+}
+
+func makeValidProjectAPIResponse() *gitlab.Response {
+	return &gitlab.Response{
+		Response: &http.Response{
+			StatusCode: http.StatusOK,
+		},
+	}
+}
+
+func makeValidProjectGroupsAPIResponse() *gitlab.Response {
+	return &gitlab.Response{
+		Response: &http.Response{
+			StatusCode: http.StatusOK,
+		},
+	}
 }
 
-func makeValidAPIResponse() *gitlab.Response {
+func makeValidGroupAPIResponse() *gitlab.Response {
 	return &gitlab.Response{
 		Response: &http.Response{
 			StatusCode: http.StatusOK,
@@ -121,10 +165,35 @@ func makeValidAPIResponse() *gitlab.Response {
 	}
 }
 
-func makeValidAPIOutput() *gitlab.ProjectVariable {
+func makeValidProjectAPIOutput() *gitlab.ProjectVariable {
 	return &gitlab.ProjectVariable{
-		Key:   "testKey",
-		Value: "",
+		Key:              "testKey",
+		Value:            "",
+		EnvironmentScope: environment,
+	}
+}
+
+func makeValidProjectGroupsAPIOutput() []*gitlab.ProjectGroup {
+	return []*gitlab.ProjectGroup{{
+		ID:       1,
+		Name:     "Group (1)",
+		FullPath: "foo",
+	}, {
+		ID:       100,
+		Name:     "Group (100)",
+		FullPath: "foo/bar/baz",
+	}, {
+		ID:       10,
+		Name:     "Group (10)",
+		FullPath: "foo/bar",
+	}}
+}
+
+func makeValidGroupAPIOutput() *gitlab.GroupVariable {
+	return &gitlab.GroupVariable{
+		Key:              "groupKey",
+		Value:            "",
+		EnvironmentScope: environment,
 	}
 }
 
@@ -133,7 +202,9 @@ func makeValidSecretManagerTestCaseCustom(tweaks ...func(smtc *secretManagerTest
 	for _, fn := range tweaks {
 		fn(smtc)
 	}
-	smtc.mockClient.WithValue(smtc.apiInputProjectID, smtc.apiInputEnv, smtc.apiInputKey, smtc.apiOutput, smtc.apiResponse, smtc.apiErr)
+	smtc.mockProjectsClient.WithValue(smtc.projectGroupsAPIOutput, smtc.projectGroupsAPIResponse, smtc.apiErr)
+	smtc.mockProjectVarClient.WithValue(smtc.apiInputEnv, smtc.apiInputKey, smtc.projectAPIOutput, smtc.projectAPIResponse, smtc.apiErr)
+	smtc.mockGroupVarClient.WithValue(smtc.groupAPIOutput, smtc.groupAPIResponse, smtc.apiErr)
 	return smtc
 }
 
@@ -144,7 +215,9 @@ func makeValidSecretManagerGetAllTestCaseCustom(tweaks ...func(smtc *secretManag
 	for _, fn := range tweaks {
 		fn(smtc)
 	}
-	smtc.mockClient.WithValue(smtc.apiInputProjectID, smtc.apiInputEnv, smtc.apiInputKey, smtc.apiOutput, smtc.apiResponse, smtc.apiErr)
+	smtc.mockProjectVarClient.WithValue(smtc.apiInputEnv, smtc.apiInputKey, smtc.projectAPIOutput, smtc.projectAPIResponse, smtc.apiErr)
+	smtc.mockGroupVarClient.WithValue(smtc.groupAPIOutput, smtc.groupAPIResponse, smtc.apiErr)
+
 	return smtc
 }
 
@@ -153,6 +226,7 @@ func makeValidSecretManagerGetAllTestCaseCustom(tweaks ...func(smtc *secretManag
 var setAPIErr = func(smtc *secretManagerTestCase) {
 	smtc.apiErr = fmt.Errorf("oh no")
 	smtc.expectError = "oh no"
+	smtc.projectAPIResponse.Response.StatusCode = http.StatusInternalServerError
 	smtc.expectedValidationResult = esv1beta1.ValidationResultError
 }
 
@@ -163,20 +237,44 @@ var setListAPIErr = func(smtc *secretManagerTestCase) {
 	smtc.expectedValidationResult = esv1beta1.ValidationResultError
 }
 
-var setListAPIRespNil = func(smtc *secretManagerTestCase) {
-	smtc.apiResponse = nil
-	smtc.expectError = errAuth
+var setProjectListAPIRespNil = func(smtc *secretManagerTestCase) {
+	smtc.projectAPIResponse = nil
+	smtc.expectError = fmt.Errorf(errProjectAuth, smtc.projectID).Error()
+	smtc.expectedValidationResult = esv1beta1.ValidationResultError
+}
+
+var setGroupListAPIRespNil = func(smtc *secretManagerTestCase) {
+	smtc.groupIDs = []string{groupid}
+	smtc.groupAPIResponse = nil
+	smtc.expectError = fmt.Errorf(errGroupAuth, groupid).Error()
+	smtc.expectedValidationResult = esv1beta1.ValidationResultError
+}
+
+var setProjectAndGroup = func(smtc *secretManagerTestCase) {
+	smtc.groupIDs = []string{groupid}
+}
+
+var setProjectAndInheritFromGroups = func(smtc *secretManagerTestCase) {
+	smtc.groupIDs = nil
+	smtc.inheritFromGroups = true
+}
+
+var setProjectListAPIRespBadCode = func(smtc *secretManagerTestCase) {
+	smtc.projectAPIResponse.StatusCode = http.StatusUnauthorized
+	smtc.expectError = fmt.Errorf(errProjectAuth, smtc.projectID).Error()
 	smtc.expectedValidationResult = esv1beta1.ValidationResultError
 }
 
-var setListAPIRespBadCode = func(smtc *secretManagerTestCase) {
-	smtc.apiResponse.StatusCode = http.StatusUnauthorized
-	smtc.expectError = errAuth
+var setGroupListAPIRespBadCode = func(smtc *secretManagerTestCase) {
+	smtc.groupIDs = []string{groupid}
+	smtc.groupAPIResponse.StatusCode = http.StatusUnauthorized
+	smtc.expectError = fmt.Errorf(errGroupAuth, groupid).Error()
 	smtc.expectedValidationResult = esv1beta1.ValidationResultError
 }
 
 var setNilMockClient = func(smtc *secretManagerTestCase) {
-	smtc.mockClient = nil
+	smtc.mockProjectVarClient = nil
+	smtc.mockGroupVarClient = nil
 	smtc.expectError = errUninitializedGitlabProvider
 }
 
@@ -265,27 +363,42 @@ func newFakeAuthorizedKey() *iamkey.Key {
 // test the sm<->gcp interface
 // make sure correct values are passed and errors are handled accordingly.
 func TestGetSecret(t *testing.T) {
-	secretValue := "changedvalue"
 	// good case: default version is set
 	// key is passed in, output is sent back
-
-	setSecretString := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput = &gitlab.ProjectVariable{
-			Key:   "testkey",
-			Value: "changedvalue",
-		}
-		smtc.expectedSecret = secretValue
+	onlyProjectSecret := func(smtc *secretManagerTestCase) {
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.groupAPIResponse = nil
+		smtc.groupAPIOutput = nil
+		smtc.expectedSecret = smtc.projectAPIOutput.Value
+	}
+	groupSecretProjectOverride := func(smtc *secretManagerTestCase) {
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.groupAPIOutput.Key = "testkey"
+		smtc.groupAPIOutput.Value = groupvalue
+		smtc.expectedSecret = smtc.projectAPIOutput.Value
+	}
+	groupWithoutProjectOverride := func(smtc *secretManagerTestCase) {
+		smtc.groupIDs = []string{groupid}
+		smtc.projectAPIResponse.Response.StatusCode = 404
+		smtc.groupAPIOutput.Key = "testkey"
+		smtc.groupAPIOutput.Value = groupvalue
+		smtc.expectedSecret = smtc.groupAPIOutput.Value
 	}
 
 	successCases := []*secretManagerTestCase{
-		makeValidSecretManagerTestCaseCustom(setSecretString),
+		makeValidSecretManagerTestCaseCustom(onlyProjectSecret),
+		makeValidSecretManagerTestCaseCustom(groupSecretProjectOverride),
+		makeValidSecretManagerTestCaseCustom(groupWithoutProjectOverride),
 		makeValidSecretManagerTestCaseCustom(setAPIErr),
 		makeValidSecretManagerTestCaseCustom(setNilMockClient),
 	}
 
 	sm := Gitlab{}
 	for k, v := range successCases {
-		sm.client = v.mockClient
+		sm.projectVariablesClient = v.mockProjectVarClient
+		sm.groupVariablesClient = v.mockGroupVarClient
+		sm.projectID = v.projectID
+		sm.groupIDs = v.groupIDs
 		out, err := sm.GetSecret(context.Background(), *v.ref)
 		if !ErrorContains(err, v.expectError) {
 			t.Errorf(defaultErrorMessage, k, err.Error(), v.expectError)
@@ -296,8 +409,22 @@ func TestGetSecret(t *testing.T) {
 	}
 }
 
+func TestResolveGroupIds(t *testing.T) {
+	v := makeValidSecretManagerTestCaseCustom()
+	sm := Gitlab{}
+	sm.projectsClient = v.mockProjectsClient
+	sm.projectID = v.projectID
+	sm.inheritFromGroups = true
+	err := sm.ResolveGroupIds()
+	if err != nil {
+		t.Errorf(defaultErrorMessage, 0, err.Error(), "")
+	}
+	if !reflect.DeepEqual(sm.groupIDs, []string{"1", "10", "100"}) {
+		t.Errorf("Expected groupIds %s, got %s", []string{"1", "10", "100"}, sm.groupIDs)
+	}
+}
+
 func TestGetAllSecrets(t *testing.T) {
-	secretValue := "changedvalue"
 	// good case: default version is set
 	// key is passed in, output is sent back
 
@@ -315,16 +442,16 @@ func TestGetAllSecrets(t *testing.T) {
 		smtc.expectError = "'find.path' is not implemented in the Gitlab provider"
 	}
 	setMatchingSecretFindString := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput = &gitlab.ProjectVariable{
+		smtc.projectAPIOutput = &gitlab.ProjectVariable{
 			Key:              "testkey",
 			Value:            "changedvalue",
 			EnvironmentScope: "test",
 		}
-		smtc.expectedSecret = secretValue
-		smtc.refFind.Name = makeFindName("test.*")
+		smtc.expectedSecret = "changedvalue"
+		smtc.refFind.Name = makeFindName(findTestPrefix)
 	}
 	setNoMatchingRegexpFindString := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput = &gitlab.ProjectVariable{
+		smtc.projectAPIOutput = &gitlab.ProjectVariable{
 			Key:              "testkey",
 			Value:            "changedvalue",
 			EnvironmentScope: "test",
@@ -333,13 +460,13 @@ func TestGetAllSecrets(t *testing.T) {
 		smtc.refFind.Name = makeFindName("foo.*")
 	}
 	setUnmatchedEnvironmentFindString := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput = &gitlab.ProjectVariable{
+		smtc.projectAPIOutput = &gitlab.ProjectVariable{
 			Key:              "testkey",
 			Value:            "changedvalue",
 			EnvironmentScope: "prod",
 		}
 		smtc.expectedSecret = ""
-		smtc.refFind.Name = makeFindName("test.*")
+		smtc.refFind.Name = makeFindName(findTestPrefix)
 	}
 
 	cases := []*secretManagerTestCase{
@@ -356,13 +483,77 @@ func TestGetAllSecrets(t *testing.T) {
 	sm := Gitlab{}
 	sm.environment = "test"
 	for k, v := range cases {
-		sm.client = v.mockClient
+		sm.projectVariablesClient = v.mockProjectVarClient
+		sm.groupVariablesClient = v.mockGroupVarClient
 		out, err := sm.GetAllSecrets(context.Background(), *v.refFind)
 		if !ErrorContains(err, v.expectError) {
 			t.Errorf(defaultErrorMessage, k, err.Error(), v.expectError)
 		}
-		if v.expectError == "" && string(out[v.apiOutput.Key]) != v.expectedSecret {
-			t.Errorf("[%d] unexpected secret: expected %s, got %s", k, v.expectedSecret, string(out[v.apiOutput.Key]))
+		if v.expectError == "" && string(out[v.projectAPIOutput.Key]) != v.expectedSecret {
+			t.Errorf("[%d] unexpected secret: expected %s, got %s", k, v.expectedSecret, string(out[v.projectAPIOutput.Key]))
+		}
+	}
+}
+
+func TestGetAllSecretsWithGroups(t *testing.T) {
+	onlyProjectSecret := func(smtc *secretManagerTestCase) {
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.refFind.Name = makeFindName(findTestPrefix)
+		smtc.groupAPIResponse = nil
+		smtc.groupAPIOutput = nil
+		smtc.expectedSecret = smtc.projectAPIOutput.Value
+	}
+	groupAndProjectSecrets := func(smtc *secretManagerTestCase) {
+		smtc.groupIDs = []string{groupid}
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.groupAPIOutput.Value = groupvalue
+		smtc.expectedData = map[string][]byte{"testKey": []byte(projectvalue), "groupKey": []byte(groupvalue)}
+		smtc.refFind.Name = makeFindName(".*Key")
+	}
+	groupAndOverrideProjectSecrets := func(smtc *secretManagerTestCase) {
+		smtc.groupIDs = []string{groupid}
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.groupAPIOutput.Key = smtc.projectAPIOutput.Key
+		smtc.groupAPIOutput.Value = groupvalue
+		smtc.expectedData = map[string][]byte{"testKey": []byte(projectvalue)}
+		smtc.refFind.Name = makeFindName(".*Key")
+	}
+	groupAndProjectWithDifferentEnvSecrets := func(smtc *secretManagerTestCase) {
+		smtc.groupIDs = []string{groupid}
+		smtc.projectAPIOutput.Value = projectvalue
+		smtc.projectAPIOutput.EnvironmentScope = "test"
+		smtc.groupAPIOutput.Key = smtc.projectAPIOutput.Key
+		smtc.groupAPIOutput.Value = groupvalue
+		smtc.expectedData = map[string][]byte{"testKey": []byte(groupvalue)}
+		smtc.refFind.Name = makeFindName(".*Key")
+	}
+
+	cases := []*secretManagerTestCase{
+		makeValidSecretManagerGetAllTestCaseCustom(onlyProjectSecret),
+		makeValidSecretManagerGetAllTestCaseCustom(groupAndProjectSecrets),
+		makeValidSecretManagerGetAllTestCaseCustom(groupAndOverrideProjectSecrets),
+		makeValidSecretManagerGetAllTestCaseCustom(groupAndProjectWithDifferentEnvSecrets),
+	}
+
+	sm := Gitlab{}
+	sm.environment = "prod"
+	for k, v := range cases {
+		sm.projectVariablesClient = v.mockProjectVarClient
+		sm.groupVariablesClient = v.mockGroupVarClient
+		sm.projectID = v.projectID
+		sm.groupIDs = v.groupIDs
+		out, err := sm.GetAllSecrets(context.Background(), *v.refFind)
+		if !ErrorContains(err, v.expectError) {
+			t.Errorf(defaultErrorMessage, k, err.Error(), v.expectError)
+		}
+		if v.expectError == "" {
+			if len(v.expectedData) > 0 {
+				if !reflect.DeepEqual(v.expectedData, out) {
+					t.Errorf("[%d] Unexpected secrets. Expected [%s], got [%s]", k, v.expectedData, out)
+				}
+			} else if string(out[v.projectAPIOutput.Key]) != v.expectedSecret {
+				t.Errorf("[%d] Unexpected secret. Expected [%s], got [%s]", k, v.expectedSecret, string(out[v.projectAPIOutput.Key]))
+			}
 		}
 	}
 }
@@ -370,13 +561,22 @@ func TestGetAllSecrets(t *testing.T) {
 func TestValidate(t *testing.T) {
 	successCases := []*secretManagerTestCase{
 		makeValidSecretManagerTestCaseCustom(),
+		makeValidSecretManagerTestCaseCustom(setProjectAndInheritFromGroups),
+		makeValidSecretManagerTestCaseCustom(setProjectAndGroup),
 		makeValidSecretManagerTestCaseCustom(setListAPIErr),
-		makeValidSecretManagerTestCaseCustom(setListAPIRespNil),
-		makeValidSecretManagerTestCaseCustom(setListAPIRespBadCode),
+		makeValidSecretManagerTestCaseCustom(setProjectListAPIRespNil),
+		makeValidSecretManagerTestCaseCustom(setProjectListAPIRespBadCode),
+		makeValidSecretManagerTestCaseCustom(setGroupListAPIRespNil),
+		makeValidSecretManagerTestCaseCustom(setGroupListAPIRespBadCode),
 	}
 	sm := Gitlab{}
 	for k, v := range successCases {
-		sm.client = v.mockClient
+		sm.projectsClient = v.mockProjectsClient
+		sm.projectVariablesClient = v.mockProjectVarClient
+		sm.groupVariablesClient = v.mockGroupVarClient
+		sm.projectID = v.projectID
+		sm.groupIDs = v.groupIDs
+		sm.inheritFromGroups = v.inheritFromGroups
 		t.Logf("%+v", v)
 		validationResult, err := sm.Validate()
 		if !ErrorContains(err, v.expectError) {
@@ -385,19 +585,22 @@ func TestValidate(t *testing.T) {
 		if validationResult != v.expectedValidationResult {
 			t.Errorf("[%d], unexpected validationResult: %s, expected: '%s'", k, validationResult, v.expectedValidationResult)
 		}
+		if sm.inheritFromGroups && sm.groupIDs[0] != "1" {
+			t.Errorf("[%d], Expected groupID '1'", k)
+		}
 	}
 }
 
 func TestGetSecretMap(t *testing.T) {
 	// good case: default version & deserialization
 	setDeserialization := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput.Value = `{"foo":"bar"}`
+		smtc.projectAPIOutput.Value = `{"foo":"bar"}`
 		smtc.expectedData["foo"] = []byte("bar")
 	}
 
 	// bad case: invalid json
 	setInvalidJSON := func(smtc *secretManagerTestCase) {
-		smtc.apiOutput.Value = `-----------------`
+		smtc.projectAPIOutput.Value = `-----------------`
 		smtc.expectError = "unable to unmarshal secret"
 	}
 
@@ -410,7 +613,8 @@ func TestGetSecretMap(t *testing.T) {
 
 	sm := Gitlab{}
 	for k, v := range successCases {
-		sm.client = v.mockClient
+		sm.projectVariablesClient = v.mockProjectVarClient
+		sm.groupVariablesClient = v.mockGroupVarClient
 		out, err := sm.GetSecretMap(context.Background(), *v.ref)
 		if !ErrorContains(err, v.expectError) {
 			t.Errorf(defaultErrorMessage, k, err.Error(), v.expectError)
@@ -462,6 +666,14 @@ func withAccessToken(name, key string, namespace *string) storeModifier {
 	}
 }
 
+func withGroups(ids []string, inherit bool) storeModifier {
+	return func(store *esv1beta1.SecretStore) *esv1beta1.SecretStore {
+		store.Spec.Provider.Gitlab.GroupIDs = ids
+		store.Spec.Provider.Gitlab.InheritFromGroups = inherit
+		return store
+	}
+}
+
 type ValidateStoreTestCase struct {
 	store *esv1beta1.SecretStore
 	err   error
@@ -472,7 +684,11 @@ func TestValidateStore(t *testing.T) {
 	testCases := []ValidateStoreTestCase{
 		{
 			store: makeSecretStore("", environment),
-			err:   fmt.Errorf("projectID cannot be empty"),
+			err:   fmt.Errorf("projectID and groupIDs must not both be empty"),
+		},
+		{
+			store: makeSecretStore(project, environment, withGroups([]string{"group1"}, true)),
+			err:   fmt.Errorf("defining groupIDs and inheritFromGroups = true is not allowed"),
 		},
 		{
 			store: makeSecretStore(project, environment, withAccessToken("", userkey, nil)),
@@ -490,6 +706,10 @@ func TestValidateStore(t *testing.T) {
 			store: makeSecretStore(project, environment, withAccessToken("userName", "userKey", nil)),
 			err:   nil,
 		},
+		{
+			store: makeSecretStore("", environment, withGroups([]string{"group1"}, false), withAccessToken("userName", "userKey", nil)),
+			err:   nil,
+		},
 	}
 	p := Gitlab{}
 	for _, tc := range testCases {