Browse Source

feat: support service account impersonation when using Workload Identity Federation with a k8s service account (#5707)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Alex Bartolomey 2 months ago
parent
commit
1f96dcd259

+ 32 - 0
providers/v1/gcp/secretmanager/workload_identity_federation.go

@@ -124,6 +124,8 @@ const (
 	awsAccessKeyIDKeyName     = "aws_access_key_id"
 	awsSecretAccessKeyKeyName = "aws_secret_access_key"
 	awsSessionTokenKeyName    = "aws_session_token"
+
+	workloadIdentityFederationServiceAccountImpersonationURLFormat = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"
 )
 
 func newWorkloadIdentityFederation(kube kclient.Client, wif *esv1.GCPWorkloadIdentityFederation, isClusterKind bool, namespace string) (*workloadIdentityFederation, error) {
@@ -170,6 +172,33 @@ func (w *workloadIdentityFederation) TokenSource(ctx context.Context) (oauth2.To
 	return externalaccount.NewTokenSource(ctx, *config)
 }
 
+func (w *workloadIdentityFederation) getGCPServiceAccountFromAnnotation(ctx context.Context, cfg *externalaccount.Config) error {
+	if w.config.ServiceAccountRef == nil {
+		return nil
+	}
+	// look up the service account and check if it has a well-known GCP WI annotation.
+	// If so, use that GCP service account for impersonation.
+	// Required if you grant secret access to a GCP service account instead of direct resource access.
+	ns := w.namespace
+	if w.isClusterKind && w.config.ServiceAccountRef.Namespace != nil {
+		ns = *w.config.ServiceAccountRef.Namespace
+	}
+	key := types.NamespacedName{
+		Name:      w.config.ServiceAccountRef.Name,
+		Namespace: ns,
+	}
+	sa := &corev1.ServiceAccount{}
+	if err := w.kubeClient.Get(ctx, key, sa); err != nil {
+		return fmt.Errorf("failed to fetch serviceaccount %q: %w", key, err)
+	}
+
+	gcpSA := sa.Annotations[gcpSAAnnotation]
+	if gcpSA != "" {
+		cfg.ServiceAccountImpersonationURL = fmt.Sprintf(workloadIdentityFederationServiceAccountImpersonationURLFormat, gcpSA)
+	}
+	return nil
+}
+
 // readCredConfig is for loading the json cred config stored in the provided configmap.
 func (w *workloadIdentityFederation) readCredConfig(ctx context.Context) (*externalaccount.Config, error) {
 	if w.config.CredConfig == nil {
@@ -215,6 +244,9 @@ func (w *workloadIdentityFederation) generateExternalAccountConfig(ctx context.C
 	if err := w.updateExternalAccountConfigWithAWSCredentialsSupplier(ctx, config); err != nil {
 		return nil, err
 	}
+	if err := w.getGCPServiceAccountFromAnnotation(ctx, config); err != nil {
+		return nil, err
+	}
 	w.updateExternalAccountConfigWithDefaultValues(config)
 	if err := validateExternalAccountConfig(config, w.config); err != nil {
 		return nil, err

+ 63 - 6
providers/v1/gcp/secretmanager/workload_identity_federation_test.go

@@ -35,12 +35,13 @@ import (
 )
 
 type workloadIdentityFederationTest struct {
-	name              string
-	wifConfig         *esv1.GCPWorkloadIdentityFederation
-	kubeObjects       []client.Object
-	genSAToken        func(context.Context, []string, string, string) (*authv1.TokenRequest, error)
-	expectError       string
-	expectTokenSource bool
+	name                   string
+	wifConfig              *esv1.GCPWorkloadIdentityFederation
+	kubeObjects            []client.Object
+	genSAToken             func(context.Context, []string, string, string) (*authv1.TokenRequest, error)
+	expectError            string
+	expectTokenSource      bool
+	expectImpersonationURL string
 }
 
 const (
@@ -49,6 +50,7 @@ const (
 	testServiceAccount                 = "test-sa"
 	testAudience                       = "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/test-pool/providers/test-provider"
 	testServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken"
+	testGCPServiceAccountEmail         = "test@test.iam.gserviceaccount.com"
 	testSAToken                        = "test-sa-token"
 	testAwsRegion                      = "us-west-2"
 	// below values taken from https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html
@@ -365,6 +367,12 @@ func TestWorkloadIdentityFederation(t *testing.T) {
 						testConfigMapKey: createInvalidK8sExternalAccountConfigWithUnallowedTokenFilePath(testAudience),
 					},
 				},
+				&corev1.ServiceAccount{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      testServiceAccount,
+						Namespace: testNamespace,
+					},
+				},
 			},
 			genSAToken: func(c context.Context, s1 []string, s2, s3 string) (*authv1.TokenRequest, error) {
 				return &authv1.TokenRequest{
@@ -376,6 +384,49 @@ func TestWorkloadIdentityFederation(t *testing.T) {
 			expectTokenSource: true,
 		},
 		{
+			name: "fail on missing service account",
+			wifConfig: &esv1.GCPWorkloadIdentityFederation{
+				ServiceAccountRef: &esmeta.ServiceAccountSelector{
+					Name:      testServiceAccount,
+					Namespace: &testNamespace,
+					Audiences: []string{testAudience},
+				},
+				Audience: testAudience,
+			},
+			expectError: "failed to fetch serviceaccount \"external-secrets-tests/test-sa\": serviceaccounts \"test-sa\" not found",
+		},
+		{
+			name: "successful kubernetes service account token federation with GCP service account impersonation",
+			wifConfig: &esv1.GCPWorkloadIdentityFederation{
+				Audience: testAudience,
+				ServiceAccountRef: &esmeta.ServiceAccountSelector{
+					Name:      testServiceAccount,
+					Namespace: &testNamespace,
+					Audiences: []string{testAudience},
+				},
+			},
+			kubeObjects: []client.Object{
+				&corev1.ServiceAccount{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      testServiceAccount,
+						Namespace: testNamespace,
+						Annotations: map[string]string{
+							gcpSAAnnotation: testGCPServiceAccountEmail,
+						},
+					},
+				},
+			},
+			genSAToken: func(c context.Context, s1 []string, s2, s3 string) (*authv1.TokenRequest, error) {
+				return &authv1.TokenRequest{
+					Status: authv1.TokenRequestStatus{
+						Token: testSAToken,
+					},
+				}, nil
+			},
+			expectImpersonationURL: testServiceAccountImpersonationURL,
+			expectTokenSource:      true,
+		},
+		{
 			name: "valid AWS credentials secret",
 			wifConfig: &esv1.GCPWorkloadIdentityFederation{
 				AwsSecurityCredentials: &esv1.AwsCredentialsConfig{
@@ -536,6 +587,12 @@ func TestWorkloadIdentityFederation(t *testing.T) {
 				namespace:        testNamespace,
 			}
 
+			if tc.expectImpersonationURL != "" {
+				cfg, cfgErr := wif.generateExternalAccountConfig(context.Background(), nil)
+				assert.NoError(t, cfgErr)
+				assert.Equal(t, tc.expectImpersonationURL, cfg.ServiceAccountImpersonationURL)
+			}
+
 			ts, err := wif.TokenSource(context.Background())
 			if tc.expectError != "" {
 				assert.Error(t, err)