Browse Source

feat: support Pod Identity authentication for Vault Provider (#5201)

* tidy: clean requestTokenWithIamAuth function to better support changes.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* feat: add ability to use pod identity (if irsa token is not set and pod identity environment variables are)

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* docs: update documentation to describe support for both IRSA and pod identity

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) remove documentation from v1beta1 and move v1 comment to the struct instead of a non-existent field) so it actually shows up in generated docs.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) properly catch both unset and empty set values

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) clean file path for os.Stat call as well.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) make type assertions safer.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) fix unnecessary fmt.Errorf with no verbs

Co-authored-by: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com>
Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) explicitly check for missing token claims.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) return correct errors for invalid token claims.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) only require the podIdentityURI to be set.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

* (peer-review) fix unnecessary nil error in claim validation.

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>

---------

Signed-off-by: Erik Westra <e.s.westra.95@gmail.com>
Co-authored-by: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Erik Westra 7 months ago
parent
commit
851d88b4d2

+ 3 - 0
apis/externalsecrets/v1/secretstore_vault_types.go

@@ -334,6 +334,9 @@ type VaultCertAuth struct {
 }
 
 // VaultIamAuth authenticates with Vault using the Vault's AWS IAM authentication method. Refer: https://developer.hashicorp.com/vault/docs/auth/aws
+//
+// When JWTAuth and SecretRef are not specified, the provider will use the controller pod's
+// identity to authenticate with AWS. This supports both IRSA and EKS Pod Identity.
 type VaultIamAuth struct {
 	// Path where the AWS auth method is enabled in Vault, e.g: "aws"
 	// +optional

+ 2 - 0
docs/api/spec.md

@@ -10208,6 +10208,8 @@ If no key for the Secret is specified, external-secret will default to &lsquo;tl
 </p>
 <p>
 <p>VaultIamAuth authenticates with Vault using the Vault&rsquo;s AWS IAM authentication method. Refer: <a href="https://developer.hashicorp.com/vault/docs/auth/aws">https://developer.hashicorp.com/vault/docs/auth/aws</a></p>
+<p>When JWTAuth and SecretRef are not specified, the provider will use the controller pod&rsquo;s
+identity to authenticate with AWS. This supports both IRSA and EKS Pod Identity.</p>
 </p>
 <table>
 <thead>

+ 7 - 2
docs/provider/hashicorp-vault.md

@@ -398,9 +398,14 @@ Reference the service account from above in the Secret Store:
 ```
 ### Controller's Pod Identity
 
-This is basicially a zero-configuration authentication approach that inherits the credentials from the controller's pod identity
+This is basically a zero-configuration authentication approach that inherits the credentials from the controller's pod identity.
 
-This approach assumes that appropriate IRSA setup is done controller's pod (i.e. IRSA enabled IAM role is created appropriately and controller's service account is annotated appropriately with the annotation "eks.amazonaws.com/role-arn" to enable IRSA)
+This approach supports both [IRSA (IAM Roles for Service Accounts)](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) and [AWS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html):
+
+- **IRSA**: Requires appropriate IRSA setup on the controller's pod (i.e. IRSA enabled IAM role is created and controller's service account is annotated with "eks.amazonaws.com/role-arn")
+- **Pod Identity**: Requires [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) setup with the controller's service account associated with an IAM role
+
+The provider automatically detects which authentication method is available and uses the appropriate one.
 
 ```yaml
 {% include 'vault-iam-store-controller-pod-identity.yaml' %}

+ 97 - 52
pkg/provider/vault/auth_iam.go

@@ -16,6 +16,7 @@ package vault
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -35,13 +36,13 @@ import (
 )
 
 const (
-	defaultAWSRegion                = "us-east-1"
-	defaultAWSAuthMountPath         = "aws"
-	errIrsaTokenEnvVarNotFoundOnPod = "expected env variable: %s not found on controller's pod"
-	errIrsaTokenFileNotFoundOnPod   = "web ddentity token file not found at %s location: %w"
-	errIrsaTokenFileNotReadable     = "could not read the web identity token from the file %s: %w"
-	errIrsaTokenNotValidJWT         = "could not parse web identity token available at %s. not a valid jwt?: %w"
-	errPodInfoNotFoundOnToken       = "could not find pod identity info on token %s: %w"
+	defaultAWSRegion              = "us-east-1"
+	defaultAWSAuthMountPath       = "aws"
+	errNoAWSAuthMethodFound       = "no AWS authentication method found: expected either IRSA or Pod Identity"
+	errIrsaTokenFileNotFoundOnPod = "web identity token file not found at %s location: %w"
+	errIrsaTokenFileNotReadable   = "could not read the web identity token from the file %s: %w"
+	errIrsaTokenNotValidJWT       = "could not parse web identity token available at %s. not a valid jwt?: %w"
+	errIrsaTokenNotValidClaims    = "could not find pod identity info on token %s"
 )
 
 func setIamAuthToken(ctx context.Context, v *client, jwtProvider util.JwtProviderFactory, assumeRoler vaultiamauth.STSProvider) (bool, error) {
@@ -60,14 +61,9 @@ func setIamAuthToken(ctx context.Context, v *client, jwtProvider util.JwtProvide
 func (c *client) requestTokenWithIamAuth(ctx context.Context, iamAuth *esv1.VaultIamAuth, isClusterKind bool, k kclient.Client, n string, jwtProvider util.JwtProviderFactory, assumeRoler vaultiamauth.STSProvider) error {
 	jwtAuth := iamAuth.JWTAuth
 	secretRefAuth := iamAuth.SecretRef
-	regionAWS := defaultAWSRegion
-	awsAuthMountPath := defaultAWSAuthMountPath
-	if iamAuth.Region != "" {
-		regionAWS = iamAuth.Region
-	}
-	if iamAuth.Path != "" {
-		awsAuthMountPath = iamAuth.Path
-	}
+	regionAWS := c.getRegionOrDefault(iamAuth.Region)
+	awsAuthMountPath := c.getAuthMountPathOrDefault(iamAuth.Path)
+
 	var creds *credentials.Credentials
 	var err error
 	if jwtAuth != nil { // use credentials from a sa explicitly defined and referenced. Highest preference is given to this method/configuration.
@@ -86,43 +82,7 @@ func (c *client) requestTokenWithIamAuth(ctx context.Context, iamAuth *esv1.Vaul
 	// Neither of jwtAuth or secretRefAuth defined. Last preference.
 	// Default to controller pod's identity
 	if jwtAuth == nil && secretRefAuth == nil {
-		// Checking if controller pod's service account is IRSA enabled and Web Identity token is available on pod
-		tokenFile, ok := os.LookupEnv(vaultiamauth.AWSWebIdentityTokenFileEnvVar)
-		if !ok {
-			return fmt.Errorf(errIrsaTokenEnvVarNotFoundOnPod, vaultiamauth.AWSWebIdentityTokenFileEnvVar) // No Web Identity(IRSA) token found on pod
-		}
-
-		// IRSA enabled service account, let's check that the jwt token filemount and file exists
-		if _, err := os.Stat(tokenFile); err != nil {
-			return fmt.Errorf(errIrsaTokenFileNotFoundOnPod, tokenFile, err)
-		}
-
-		// everything looks good so far, let's fetch the jwt token from AWS_WEB_IDENTITY_TOKEN_FILE
-		jwtByte, err := os.ReadFile(filepath.Clean(tokenFile))
-		if err != nil {
-			return fmt.Errorf(errIrsaTokenFileNotReadable, tokenFile, err)
-		}
-
-		// let's parse the jwt token
-		parser := jwt.NewParser(jwt.WithoutClaimsValidation())
-
-		token, _, err := parser.ParseUnverified(string(jwtByte), jwt.MapClaims{})
-		if err != nil {
-			return fmt.Errorf(errIrsaTokenNotValidJWT, tokenFile, err) // JWT token parser error
-		}
-
-		var ns string
-		var sa string
-
-		// let's fetch the namespace and serviceaccount from parsed jwt token
-		if claims, ok := token.Claims.(jwt.MapClaims); ok {
-			ns = claims["kubernetes.io"].(map[string]any)["namespace"].(string)
-			sa = claims["kubernetes.io"].(map[string]any)["serviceaccount"].(map[string]any)["name"].(string)
-		} else {
-			return fmt.Errorf(errPodInfoNotFoundOnToken, tokenFile, err)
-		}
-
-		creds, err = vaultiamauth.CredsFromControllerServiceAccount(ctx, sa, ns, regionAWS, k, jwtProvider)
+		creds, err = c.getControllerPodCredentials(ctx, regionAWS, k, jwtProvider)
 		if err != nil {
 			return err
 		}
@@ -183,3 +143,88 @@ func (c *client) requestTokenWithIamAuth(ctx context.Context, iamAuth *esv1.Vaul
 	}
 	return nil
 }
+
+func (c *client) getRegionOrDefault(region string) string {
+	if region != "" {
+		return region
+	}
+	return defaultAWSRegion
+}
+
+func (c *client) getAuthMountPathOrDefault(path string) string {
+	if path != "" {
+		return path
+	}
+	return defaultAWSAuthMountPath
+}
+
+func (c *client) getControllerPodCredentials(ctx context.Context, region string, k kclient.Client, jwtProvider util.JwtProviderFactory) (*credentials.Credentials, error) {
+	// First try IRSA (Web Identity Token) - checking if controller pod's service account is IRSA enabled
+	tokenFile := os.Getenv(vaultiamauth.AWSWebIdentityTokenFileEnvVar)
+	if tokenFile != "" {
+		logger.V(1).Info("using IRSA token for authentication")
+		return c.getCredsFromIRSAToken(ctx, tokenFile, region, k, jwtProvider)
+	}
+
+	// Check for Pod Identity environment variables.
+	podIdentityURI := os.Getenv(vaultiamauth.AWSContainerCredentialsFullURIEnvVar)
+
+	if podIdentityURI != "" {
+		logger.V(1).Info("using Pod Identity for authentication")
+		// Return nil to let AWS SDK v1 container credential provider handle Pod Identity automatically
+		return nil, nil
+	}
+
+	// No IRSA or Pod Identity found.
+	return nil, errors.New(errNoAWSAuthMethodFound)
+}
+
+func (c *client) getCredsFromIRSAToken(ctx context.Context, tokenFile, region string, k kclient.Client, jwtProvider util.JwtProviderFactory) (*credentials.Credentials, error) {
+	// IRSA enabled service account, let's check that the jwt token filemount and file exists
+	if _, err := os.Stat(filepath.Clean(tokenFile)); err != nil {
+		return nil, fmt.Errorf(errIrsaTokenFileNotFoundOnPod, tokenFile, err)
+	}
+
+	// everything looks good so far, let's fetch the jwt token from AWS_WEB_IDENTITY_TOKEN_FILE
+	jwtByte, err := os.ReadFile(filepath.Clean(tokenFile))
+	if err != nil {
+		return nil, fmt.Errorf(errIrsaTokenFileNotReadable, tokenFile, err)
+	}
+
+	// let's parse the jwt token
+	parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
+	token, _, err := parser.ParseUnverified(string(jwtByte), jwt.MapClaims{})
+	if err != nil {
+		return nil, fmt.Errorf(errIrsaTokenNotValidJWT, tokenFile, err) // JWT token parser error
+	}
+
+	var ns string
+	var sa string
+
+	// let's fetch the namespace and serviceaccount from parsed jwt token
+	claims, ok := token.Claims.(jwt.MapClaims)
+	if !ok {
+		return nil, fmt.Errorf(errIrsaTokenNotValidClaims, tokenFile)
+	}
+
+	k8s, ok := claims["kubernetes.io"].(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf(errIrsaTokenNotValidClaims, tokenFile)
+	}
+
+	ns, ok = k8s["namespace"].(string)
+	if !ok {
+		return nil, fmt.Errorf(errIrsaTokenNotValidClaims, tokenFile)
+	}
+	saMap, ok := k8s["serviceaccount"].(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf(errIrsaTokenNotValidClaims, tokenFile)
+	}
+	sa, ok = saMap["name"].(string)
+	if !ok {
+		return nil, fmt.Errorf(errIrsaTokenNotValidClaims, tokenFile)
+	}
+
+	return vaultiamauth.CredsFromControllerServiceAccount(ctx, sa, ns, region, k, jwtProvider)
+}

+ 3 - 2
pkg/provider/vault/iamauth/iamauth.go

@@ -54,8 +54,9 @@ const (
 	audienceAnnotation   = "eks.amazonaws.com/audience"
 	defaultTokenAudience = "sts.amazonaws.com"
 
-	STSEndpointEnv                = "AWS_STS_ENDPOINT"
-	AWSWebIdentityTokenFileEnvVar = "AWS_WEB_IDENTITY_TOKEN_FILE"
+	STSEndpointEnv                       = "AWS_STS_ENDPOINT"
+	AWSWebIdentityTokenFileEnvVar        = "AWS_WEB_IDENTITY_TOKEN_FILE"
+	AWSContainerCredentialsFullURIEnvVar = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
 )
 
 // DefaultJWTProvider returns a credentials.Provider that calls the AssumeRoleWithWebidentity

+ 70 - 0
pkg/provider/vault/provider_test.go

@@ -200,6 +200,32 @@ func makeValidSecretStoreWithIamAuthSecret() *esv1.SecretStore {
 	}
 }
 
+func makeValidSecretStoreWithIamAuthControllerPod() *esv1.SecretStore {
+	return &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "vault-store",
+			Namespace: "default",
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Vault: &esv1.VaultProvider{
+					Server:  "https://vault.example.com:8200",
+					Path:    &secretStorePath,
+					Version: esv1.VaultKVStoreV2,
+					Auth: &esv1.VaultAuth{
+						Iam: &esv1.VaultIamAuth{
+							Path:   "aws",
+							Region: "us-east-1",
+							Role:   "vault-role",
+							// No JWTAuth or SecretRef - will use controller pod identity
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
 type secretStoreTweakFn func(s *esv1.SecretStore)
 
 func makeSecretStore(tweaks ...secretStoreTweakFn) *esv1.SecretStore {
@@ -686,6 +712,19 @@ MIIFkTCCA3mgAwIBAgIUBEUg3m/WqAsWHG4Q/II3IePFfuowDQYJKoZIhvcNAQELBQAwWDELMAkGA1UE
 			},
 			want: want{},
 		},
+		"IamAuthControllerPodNoEnvVars": {
+			reason: "Should return error when IAM controller pod auth has no AWS environment variables",
+			args: args{
+				store:         makeValidSecretStoreWithIamAuthControllerPod(),
+				ns:            "default",
+				kube:          clientfake.NewClientBuilder().Build(),
+				corev1:        utilfake.NewCreateTokenMock().WithToken("ok"),
+				newClientFunc: fake.ClientWithLoginMock,
+			},
+			want: want{
+				err: errors.New(errNoAWSAuthMethodFound),
+			},
+		},
 	}
 
 	for name, tc := range cases {
@@ -708,6 +747,37 @@ func vaultTest(t *testing.T, _ string, tc testCase) {
 	}
 }
 
+func TestGetControllerPodCredentials(t *testing.T) {
+	client := &client{storeKind: esv1.SecretStoreKind}
+	ctx := context.Background()
+	region := "us-east-1"
+	kube := clientfake.NewClientBuilder().Build()
+
+	t.Run("PodIdentityEnvVars", func(t *testing.T) {
+		t.Setenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", "http://169.254.170.23/v1/credentials")
+
+		creds, err := client.getControllerPodCredentials(ctx, region, kube, nil)
+
+		// Should succeed and return nil (indicating AWS SDK should handle it)
+		if err != nil {
+			t.Errorf("Expected no error, got: %v", err)
+		}
+		if creds != nil {
+			t.Errorf("Expected nil credentials for Pod Identity, got: %v", creds)
+		}
+	})
+
+	t.Run("NoEnvVars", func(t *testing.T) {
+		// Pod Identity URI is not set.
+		_, err := client.getControllerPodCredentials(ctx, region, kube, nil)
+
+		expectedErr := fmt.Errorf(errNoAWSAuthMethodFound)
+		if diff := cmp.Diff(expectedErr, err, EquateErrors()); diff != "" {
+			t.Errorf("TestGetControllerPodCredentials/NoEnvVars: -want error, +got error:\n%s", diff)
+		}
+	})
+}
+
 func TestCache(t *testing.T) {
 	t.Cleanup(resetCache)
 	enableCache = true