Просмотр исходного кода

feat(aws): add parameterstore v2 provider e2e coverage

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner 2 месяцев назад
Родитель
Сommit
741aee6c14
44 измененных файлов с 3882 добавлено и 183 удалено
  1. 3 0
      apis/provider/aws/v2alpha1/groupversion_info.go
  2. 93 0
      apis/provider/aws/v2alpha1/parameterstore_types.go
  3. 118 0
      apis/provider/aws/v2alpha1/zz_generated.deepcopy.go
  4. 1 0
      config/crds/bases/kustomization.yaml
  5. 294 0
      config/crds/bases/provider.external-secrets.io_parameterstores.yaml
  6. 284 0
      deploy/crds/bundle.yaml
  7. 2 2
      e2e/Makefile
  8. 39 6
      e2e/framework/addon/chart.go
  9. 20 0
      e2e/framework/addon/chart_test.go
  10. 13 0
      e2e/framework/addon/eso_v2_mutators.go
  11. 10 0
      e2e/framework/addon/eso_v2_mutators_test.go
  12. 5 1
      e2e/framework/framework.go
  13. 7 0
      e2e/framework/testcase.go
  14. 60 0
      e2e/framework/util/util.go
  15. 126 0
      e2e/framework/util/util_test.go
  16. 5 1
      e2e/framework/v2/helpers.go
  17. 145 0
      e2e/suites/provider/cases/aws/common_test.go
  18. 109 0
      e2e/suites/provider/cases/aws/parameterstore/clusterprovider_v2.go
  19. 343 0
      e2e/suites/provider/cases/aws/parameterstore/provider_support_v2.go
  20. 56 0
      e2e/suites/provider/cases/aws/parameterstore/provider_support_v2_test.go
  21. 120 0
      e2e/suites/provider/cases/aws/parameterstore/provider_v2.go
  22. 72 0
      e2e/suites/provider/cases/aws/parameterstore/provider_v2_test.go
  23. 152 0
      e2e/suites/provider/cases/aws/parameterstore/push_v2.go
  24. 109 0
      e2e/suites/provider/cases/aws/secretsmanager/clusterprovider_v2.go
  25. 15 63
      e2e/suites/provider/cases/aws/secretsmanager/provider.go
  26. 484 0
      e2e/suites/provider/cases/aws/secretsmanager/provider_support.go
  27. 210 0
      e2e/suites/provider/cases/aws/secretsmanager/provider_support_test.go
  28. 181 0
      e2e/suites/provider/cases/aws/secretsmanager/provider_v2.go
  29. 161 0
      e2e/suites/provider/cases/aws/secretsmanager/push_v2.go
  30. 88 0
      e2e/suites/provider/cases/aws/secretsmanager/secretsmanager_v2_managed.go
  31. 131 0
      e2e/suites/provider/cases/aws/v2_support.go
  32. 46 42
      e2e/suites/provider/cases/common/fake_provider.go
  33. 45 0
      e2e/suites/provider/cases/common/fake_provider_test.go
  34. 29 0
      e2e/suites/provider/cases/common/provider_runtime_test.go
  35. 29 25
      e2e/suites/provider/cases/common/push_secret.go
  36. 82 0
      e2e/suites/provider/cases/fake/provider_v2_test.go
  37. 7 0
      e2e/suites/provider/cases/fake/regressions.go
  38. 53 26
      providers/v2/aws/config.go
  39. 120 0
      providers/v2/aws/config_test.go
  40. 7 4
      providers/v2/aws/main.go
  41. 6 1
      providers/v2/aws/provider.yaml
  42. 1 4
      providers/v2/fake/main.go
  43. 0 4
      providers/v2/hack/templates/main.go.tmpl
  44. 1 4
      providers/v2/kubernetes/main.go

+ 3 - 0
apis/provider/aws/v2alpha1/groupversion_info.go

@@ -28,6 +28,9 @@ var (
 	// SecretsManagerKind is the kind name used for SecretsManager resources.
 	SecretsManagerKind = "SecretsManager"
 
+	// ParameterStoreKind is the kind name used for ParameterStore resources.
+	ParameterStoreKind = "ParameterStore"
+
 	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
 	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
 

+ 93 - 0
apis/provider/aws/v2alpha1/parameterstore_types.go

@@ -0,0 +1,93 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+// ParameterStoreSpec defines the desired state of ParameterStore.
+type ParameterStoreSpec struct {
+	// Auth defines the information necessary to authenticate against AWS
+	// if not set aws sdk will infer credentials from your environment
+	// see: https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials
+	// +optional
+	Auth v1.AWSAuth `json:"auth,omitempty"`
+
+	// Role is a Role ARN which the provider will assume
+	// +optional
+	Role string `json:"role,omitempty"`
+
+	// AWS Region to be used for the provider
+	Region string `json:"region"`
+
+	// AdditionalRoles is a chained list of Role ARNs which the provider will sequentially assume before assuming the Role
+	// +optional
+	AdditionalRoles []string `json:"additionalRoles,omitempty"`
+
+	// AWS External ID set on assumed IAM roles
+	ExternalID string `json:"externalID,omitempty"`
+
+	// AWS STS assume role session tags
+	// +optional
+	SessionTags []*v1.Tag `json:"sessionTags,omitempty"`
+
+	// AWS STS assume role transitive session tags. Required when multiple rules are used with the provider
+	// +optional
+	TransitiveTagKeys []string `json:"transitiveTagKeys,omitempty"`
+
+	// Prefix adds a prefix to all retrieved values.
+	// +optional
+	Prefix string `json:"prefix,omitempty"`
+}
+
+// ParameterStoreStatus defines the observed state of ParameterStore.
+type ParameterStoreStatus struct {
+	// Conditions represent the latest available observations of the resource's state.
+	// +optional
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:scope=Namespaced,categories={externalsecrets},shortName=ssm
+// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.spec.region`
+// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
+
+// ParameterStore is the Schema for AWS Parameter Store provider configuration.
+type ParameterStore struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   ParameterStoreSpec   `json:"spec,omitempty"`
+	Status ParameterStoreStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// ParameterStoreList contains a list of ParameterStore.
+type ParameterStoreList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ParameterStore `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&ParameterStore{}, &ParameterStoreList{})
+}

+ 118 - 0
apis/provider/aws/v2alpha1/zz_generated.deepcopy.go

@@ -26,6 +26,124 @@ import (
 	runtime "k8s.io/apimachinery/pkg/runtime"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ParameterStore) DeepCopyInto(out *ParameterStore) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParameterStore.
+func (in *ParameterStore) DeepCopy() *ParameterStore {
+	if in == nil {
+		return nil
+	}
+	out := new(ParameterStore)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ParameterStore) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ParameterStoreList) DeepCopyInto(out *ParameterStoreList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ParameterStore, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParameterStoreList.
+func (in *ParameterStoreList) DeepCopy() *ParameterStoreList {
+	if in == nil {
+		return nil
+	}
+	out := new(ParameterStoreList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ParameterStoreList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ParameterStoreSpec) DeepCopyInto(out *ParameterStoreSpec) {
+	*out = *in
+	in.Auth.DeepCopyInto(&out.Auth)
+	if in.AdditionalRoles != nil {
+		in, out := &in.AdditionalRoles, &out.AdditionalRoles
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.SessionTags != nil {
+		in, out := &in.SessionTags, &out.SessionTags
+		*out = make([]*v1.Tag, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(v1.Tag)
+				**out = **in
+			}
+		}
+	}
+	if in.TransitiveTagKeys != nil {
+		in, out := &in.TransitiveTagKeys, &out.TransitiveTagKeys
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParameterStoreSpec.
+func (in *ParameterStoreSpec) DeepCopy() *ParameterStoreSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ParameterStoreSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ParameterStoreStatus) DeepCopyInto(out *ParameterStoreStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParameterStoreStatus.
+func (in *ParameterStoreStatus) DeepCopy() *ParameterStoreStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ParameterStoreStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *SecretsManager) DeepCopyInto(out *SecretsManager) {
 	*out = *in

+ 1 - 0
config/crds/bases/kustomization.yaml

@@ -29,4 +29,5 @@ resources:
   - generators.external-secrets.io_webhooks.yaml
   - provider.external-secrets.io_fakes.yaml
   - provider.external-secrets.io_kubernetes.yaml
+  - provider.external-secrets.io_parameterstores.yaml
   - provider.external-secrets.io_secretsmanagers.yaml

+ 294 - 0
config/crds/bases/provider.external-secrets.io_parameterstores.yaml

@@ -0,0 +1,294 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.19.0
+  name: parameterstores.provider.external-secrets.io
+spec:
+  group: provider.external-secrets.io
+  names:
+    categories:
+    - externalsecrets
+    kind: ParameterStore
+    listKind: ParameterStoreList
+    plural: parameterstores
+    shortNames:
+    - ssm
+    singular: parameterstore
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.region
+      name: Region
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v2alpha1
+    schema:
+      openAPIV3Schema:
+        description: ParameterStore is the Schema for AWS Parameter Store provider
+          configuration.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ParameterStoreSpec defines the desired state of ParameterStore.
+            properties:
+              additionalRoles:
+                description: AdditionalRoles is a chained list of Role ARNs which
+                  the provider will sequentially assume before assuming the Role
+                items:
+                  type: string
+                type: array
+              auth:
+                description: |-
+                  Auth defines the information necessary to authenticate against AWS
+                  if not set aws sdk will infer credentials from your environment
+                  see: https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials
+                properties:
+                  jwt:
+                    description: AWSJWTAuth stores reference to Authenticate against
+                      AWS using service account tokens.
+                    properties:
+                      serviceAccountRef:
+                        description: ServiceAccountSelector is a reference to a ServiceAccount
+                          resource.
+                        properties:
+                          audiences:
+                            description: |-
+                              Audience specifies the `aud` claim for the service account token
+                              If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                              then this audiences will be appended to the list
+                            items:
+                              type: string
+                            type: array
+                          name:
+                            description: The name of the ServiceAccount resource being
+                              referred to.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                          namespace:
+                            description: |-
+                              Namespace of the resource being referred to.
+                              Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                            maxLength: 63
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                            type: string
+                        required:
+                        - name
+                        type: object
+                    type: object
+                  secretRef:
+                    description: |-
+                      AWSAuthSecretRef holds secret references for AWS credentials
+                      both AccessKeyID and SecretAccessKey must be defined in order to properly authenticate.
+                    properties:
+                      accessKeyIDSecretRef:
+                        description: The AccessKeyID is used for authentication
+                        properties:
+                          key:
+                            description: |-
+                              A key in the referenced Secret.
+                              Some instances of this field may be defaulted, in others it may be required.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[-._a-zA-Z0-9]+$
+                            type: string
+                          name:
+                            description: The name of the Secret resource being referred
+                              to.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                          namespace:
+                            description: |-
+                              The namespace of the Secret resource being referred to.
+                              Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                            maxLength: 63
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                            type: string
+                        type: object
+                      secretAccessKeySecretRef:
+                        description: The SecretAccessKey is used for authentication
+                        properties:
+                          key:
+                            description: |-
+                              A key in the referenced Secret.
+                              Some instances of this field may be defaulted, in others it may be required.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[-._a-zA-Z0-9]+$
+                            type: string
+                          name:
+                            description: The name of the Secret resource being referred
+                              to.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                          namespace:
+                            description: |-
+                              The namespace of the Secret resource being referred to.
+                              Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                            maxLength: 63
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                            type: string
+                        type: object
+                      sessionTokenSecretRef:
+                        description: |-
+                          The SessionToken used for authentication
+                          This must be defined if AccessKeyID and SecretAccessKey are temporary credentials
+                          see: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html
+                        properties:
+                          key:
+                            description: |-
+                              A key in the referenced Secret.
+                              Some instances of this field may be defaulted, in others it may be required.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[-._a-zA-Z0-9]+$
+                            type: string
+                          name:
+                            description: The name of the Secret resource being referred
+                              to.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                          namespace:
+                            description: |-
+                              The namespace of the Secret resource being referred to.
+                              Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                            maxLength: 63
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                            type: string
+                        type: object
+                    type: object
+                type: object
+              externalID:
+                description: AWS External ID set on assumed IAM roles
+                type: string
+              prefix:
+                description: Prefix adds a prefix to all retrieved values.
+                type: string
+              region:
+                description: AWS Region to be used for the provider
+                type: string
+              role:
+                description: Role is a Role ARN which the provider will assume
+                type: string
+              sessionTags:
+                description: AWS STS assume role session tags
+                items:
+                  description: |-
+                    Tag is a key-value pair that can be attached to an AWS resource.
+                    see: https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html
+                  properties:
+                    key:
+                      type: string
+                    value:
+                      type: string
+                  required:
+                  - key
+                  - value
+                  type: object
+                type: array
+              transitiveTagKeys:
+                description: AWS STS assume role transitive session tags. Required
+                  when multiple rules are used with the provider
+                items:
+                  type: string
+                type: array
+            required:
+            - region
+            type: object
+          status:
+            description: ParameterStoreStatus defines the observed state of ParameterStore.
+            properties:
+              conditions:
+                description: Conditions represent the latest available observations
+                  of the resource's state.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}

+ 284 - 0
deploy/crds/bundle.yaml

@@ -30446,6 +30446,290 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.19.0
+  name: parameterstores.provider.external-secrets.io
+spec:
+  group: provider.external-secrets.io
+  names:
+    categories:
+      - externalsecrets
+    kind: ParameterStore
+    listKind: ParameterStoreList
+    plural: parameterstores
+    shortNames:
+      - ssm
+    singular: parameterstore
+  scope: Namespaced
+  versions:
+    - additionalPrinterColumns:
+        - jsonPath: .spec.region
+          name: Region
+          type: string
+        - jsonPath: .metadata.creationTimestamp
+          name: Age
+          type: date
+      name: v2alpha1
+      schema:
+        openAPIV3Schema:
+          description: ParameterStore is the Schema for AWS Parameter Store provider configuration.
+          properties:
+            apiVersion:
+              description: |-
+                APIVersion defines the versioned schema of this representation of an object.
+                Servers should convert recognized schemas to the latest internal value, and
+                may reject unrecognized values.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+              type: string
+            kind:
+              description: |-
+                Kind is a string value representing the REST resource this object represents.
+                Servers may infer this from the endpoint the client submits requests to.
+                Cannot be updated.
+                In CamelCase.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+              type: string
+            metadata:
+              type: object
+            spec:
+              description: ParameterStoreSpec defines the desired state of ParameterStore.
+              properties:
+                additionalRoles:
+                  description: AdditionalRoles is a chained list of Role ARNs which the provider will sequentially assume before assuming the Role
+                  items:
+                    type: string
+                  type: array
+                auth:
+                  description: |-
+                    Auth defines the information necessary to authenticate against AWS
+                    if not set aws sdk will infer credentials from your environment
+                    see: https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials
+                  properties:
+                    jwt:
+                      description: AWSJWTAuth stores reference to Authenticate against AWS using service account tokens.
+                      properties:
+                        serviceAccountRef:
+                          description: ServiceAccountSelector is a reference to a ServiceAccount resource.
+                          properties:
+                            audiences:
+                              description: |-
+                                Audience specifies the `aud` claim for the service account token
+                                If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                                then this audiences will be appended to the list
+                              items:
+                                type: string
+                              type: array
+                            name:
+                              description: The name of the ServiceAccount resource being referred to.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                            namespace:
+                              description: |-
+                                Namespace of the resource being referred to.
+                                Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                              maxLength: 63
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                              type: string
+                          required:
+                            - name
+                          type: object
+                      type: object
+                    secretRef:
+                      description: |-
+                        AWSAuthSecretRef holds secret references for AWS credentials
+                        both AccessKeyID and SecretAccessKey must be defined in order to properly authenticate.
+                      properties:
+                        accessKeyIDSecretRef:
+                          description: The AccessKeyID is used for authentication
+                          properties:
+                            key:
+                              description: |-
+                                A key in the referenced Secret.
+                                Some instances of this field may be defaulted, in others it may be required.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[-._a-zA-Z0-9]+$
+                              type: string
+                            name:
+                              description: The name of the Secret resource being referred to.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                            namespace:
+                              description: |-
+                                The namespace of the Secret resource being referred to.
+                                Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                              maxLength: 63
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                              type: string
+                          type: object
+                        secretAccessKeySecretRef:
+                          description: The SecretAccessKey is used for authentication
+                          properties:
+                            key:
+                              description: |-
+                                A key in the referenced Secret.
+                                Some instances of this field may be defaulted, in others it may be required.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[-._a-zA-Z0-9]+$
+                              type: string
+                            name:
+                              description: The name of the Secret resource being referred to.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                            namespace:
+                              description: |-
+                                The namespace of the Secret resource being referred to.
+                                Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                              maxLength: 63
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                              type: string
+                          type: object
+                        sessionTokenSecretRef:
+                          description: |-
+                            The SessionToken used for authentication
+                            This must be defined if AccessKeyID and SecretAccessKey are temporary credentials
+                            see: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html
+                          properties:
+                            key:
+                              description: |-
+                                A key in the referenced Secret.
+                                Some instances of this field may be defaulted, in others it may be required.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[-._a-zA-Z0-9]+$
+                              type: string
+                            name:
+                              description: The name of the Secret resource being referred to.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                            namespace:
+                              description: |-
+                                The namespace of the Secret resource being referred to.
+                                Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                              maxLength: 63
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                              type: string
+                          type: object
+                      type: object
+                  type: object
+                externalID:
+                  description: AWS External ID set on assumed IAM roles
+                  type: string
+                prefix:
+                  description: Prefix adds a prefix to all retrieved values.
+                  type: string
+                region:
+                  description: AWS Region to be used for the provider
+                  type: string
+                role:
+                  description: Role is a Role ARN which the provider will assume
+                  type: string
+                sessionTags:
+                  description: AWS STS assume role session tags
+                  items:
+                    description: |-
+                      Tag is a key-value pair that can be attached to an AWS resource.
+                      see: https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html
+                    properties:
+                      key:
+                        type: string
+                      value:
+                        type: string
+                    required:
+                      - key
+                      - value
+                    type: object
+                  type: array
+                transitiveTagKeys:
+                  description: AWS STS assume role transitive session tags. Required when multiple rules are used with the provider
+                  items:
+                    type: string
+                  type: array
+              required:
+                - region
+              type: object
+            status:
+              description: ParameterStoreStatus defines the observed state of ParameterStore.
+              properties:
+                conditions:
+                  description: Conditions represent the latest available observations of the resource's state.
+                  items:
+                    description: Condition contains details for one aspect of the current state of this API Resource.
+                    properties:
+                      lastTransitionTime:
+                        description: |-
+                          lastTransitionTime is the last time the condition transitioned from one status to another.
+                          This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                        format: date-time
+                        type: string
+                      message:
+                        description: |-
+                          message is a human readable message indicating details about the transition.
+                          This may be an empty string.
+                        maxLength: 32768
+                        type: string
+                      observedGeneration:
+                        description: |-
+                          observedGeneration represents the .metadata.generation that the condition was set based upon.
+                          For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                          with respect to the current state of the instance.
+                        format: int64
+                        minimum: 0
+                        type: integer
+                      reason:
+                        description: |-
+                          reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                          Producers of specific condition types may define expected values and meanings for this field,
+                          and whether the values are considered a guaranteed API.
+                          The value should be a CamelCase string.
+                          This field may not be empty.
+                        maxLength: 1024
+                        minLength: 1
+                        pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                        type: string
+                      status:
+                        description: status of the condition, one of True, False, Unknown.
+                        enum:
+                          - "True"
+                          - "False"
+                          - Unknown
+                        type: string
+                      type:
+                        description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        maxLength: 316
+                        pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                        type: string
+                    required:
+                      - lastTransitionTime
+                      - message
+                      - reason
+                      - status
+                      - type
+                    type: object
+                  type: array
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources:
+        status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.19.0

+ 2 - 2
e2e/Makefile

@@ -108,7 +108,7 @@ test.managed: e2e-image ## Run e2e tests against current kube context
 
 
 e2e-bin: install-ginkgo
-	   CGO_ENABLED=0 ginkgo build ./suites/...
+	   GOWORK=off CGO_ENABLED=0 ginkgo build ./suites/...
 
 e2e-image: e2e-bin
 	-rm -rf ./k8s/deploy
@@ -121,7 +121,7 @@ e2e-image: e2e-bin
 GINKGO_VERSION := $(shell grep 'github.com/onsi/ginkgo/v2' go.mod | awk '{print $$2}')
 install-ginkgo:
 	   @echo "Installing ginkgo version $(GINKGO_VERSION) from go.mod"
-	   go install github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION)
+	   GOWORK=off go install github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION)
 
 help: ## displays this help message
 	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_\/-]+:.*?## / {printf "\033[34m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | \

+ 39 - 6
e2e/framework/addon/chart.go

@@ -22,6 +22,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 
 	. "github.com/onsi/ginkgo/v2"
 	corev1 "k8s.io/api/core/v1"
@@ -81,11 +82,21 @@ func (c *HelmChart) Install() error {
 	}
 
 	args := c.installArgs()
-	log.Logf("installing chart with args: %+q", args)
-	cmd := exec.Command("helm", args...)
-	output, err := cmd.CombinedOutput()
+	output, err := c.runInstall(args)
 	if err != nil {
-		return fmt.Errorf("unable to run cmd: %w: %s", err, string(output))
+		if !isHelmReleaseNameInUseError(string(output)) {
+			return fmt.Errorf("unable to run cmd: %w: %s", err, string(output))
+		}
+
+		log.Logf("helm install detected stale release state for %q in namespace %q; attempting cleanup", c.ReleaseName, c.Namespace)
+		if cleanupErr := c.cleanupExistingRelease(); cleanupErr != nil {
+			return fmt.Errorf("unable to clean stale helm release %s/%s after install failure: %w", c.Namespace, c.ReleaseName, cleanupErr)
+		}
+
+		output, err = c.runInstall(args)
+		if err != nil {
+			return fmt.Errorf("unable to run cmd after stale release cleanup: %w: %s", err, string(output))
+		}
 	}
 
 	log.Logf("finished running chart install")
@@ -126,10 +137,32 @@ func (c *HelmChart) installArgs() []string {
 	return args
 }
 
+func (c *HelmChart) uninstallArgs() []string {
+	return []string{"uninstall", "--namespace", c.Namespace, c.ReleaseName, "--wait", "--ignore-not-found"}
+}
+
+func (c *HelmChart) runInstall(args []string) ([]byte, error) {
+	log.Logf("installing chart with args: %+q", args)
+	cmd := exec.Command("helm", args...)
+	return cmd.CombinedOutput()
+}
+
+func (c *HelmChart) cleanupExistingRelease() error {
+	cmd := exec.Command("helm", c.uninstallArgs()...)
+	output, err := cmd.CombinedOutput()
+	if err != nil && !strings.Contains(string(output), "release: not found") {
+		return fmt.Errorf("unable to uninstall stale helm release: %w: %s", err, string(output))
+	}
+	return nil
+}
+
+func isHelmReleaseNameInUseError(output string) bool {
+	return strings.Contains(output, "cannot re-use a name that is still in use")
+}
+
 // Uninstall removes the chart aswell as the repo.
 func (c *HelmChart) Uninstall() error {
-	args := []string{"uninstall", "--namespace", c.Namespace, c.ReleaseName, "--wait"}
-	cmd := exec.Command("helm", args...)
+	cmd := exec.Command("helm", c.uninstallArgs()...)
 	output, err := cmd.CombinedOutput()
 	if err != nil {
 		return fmt.Errorf("unable to uninstall helm release: %w: %s", err, string(output))

+ 20 - 0
e2e/framework/addon/chart_test.go

@@ -63,6 +63,26 @@ func TestInstallArgsOmitDependencyUpdateWhenSkipped(t *testing.T) {
 	}
 }
 
+func TestUninstallArgsIncludeIgnoreNotFound(t *testing.T) {
+	args := (&HelmChart{
+		ReleaseName: "external-secrets",
+		Namespace:   "external-secrets-system",
+	}).uninstallArgs()
+
+	if !contains(args, "--ignore-not-found") {
+		t.Fatalf("expected uninstall args to include --ignore-not-found, got %v", args)
+	}
+}
+
+func TestIsHelmReleaseNameInUseError(t *testing.T) {
+	if !isHelmReleaseNameInUseError("Error: INSTALLATION FAILED: cannot re-use a name that is still in use") {
+		t.Fatal("expected stale release message to be detected")
+	}
+	if isHelmReleaseNameInUseError("release: not found") {
+		t.Fatal("did not expect unrelated helm output to be detected as stale release state")
+	}
+}
+
 func contains(values []string, want string) bool {
 	for _, value := range values {
 		if value == want {

+ 13 - 0
e2e/framework/addon/eso_v2_mutators.go

@@ -58,6 +58,19 @@ func WithV2AWSProvider() MutationFunc {
 	}
 }
 
+func WithV2ProviderServiceAccount(providerName, serviceAccountName string) MutationFunc {
+	return func(eso *ESO) {
+		index := findProviderIndex(eso.HelmChart, providerName)
+		if index < 0 {
+			panic("provider entry must exist before overriding service account")
+		}
+
+		prefix := "providers.list[" + strconv.Itoa(index) + "].serviceAccount"
+		setOrAppendVar(eso.HelmChart, StringTuple{Key: prefix + ".create", Value: "false"})
+		setOrAppendVar(eso.HelmChart, StringTuple{Key: prefix + ".name", Value: serviceAccountName})
+	}
+}
+
 func setOrAppendVar(chart *HelmChart, variable StringTuple) {
 	for i := range chart.Vars {
 		if chart.Vars[i].Key == variable.Key {

+ 10 - 0
e2e/framework/addon/eso_v2_mutators_test.go

@@ -194,6 +194,16 @@ func TestWithV2FakeProviderEnforcesRequiredFlags(t *testing.T) {
 	assertVarValue(t, eso.HelmChart, "providerDefaults.replicaCount", "8")
 }
 
+func TestWithV2ProviderServiceAccountOverridesAWSInPlace(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2AWSProvider())
+	WithV2ProviderServiceAccount("aws", "irsa-sa")(eso)
+
+	assertVarValue(t, eso.HelmChart, "providers.list[0].serviceAccount.create", "false")
+	assertVarValue(t, eso.HelmChart, "providers.list[0].serviceAccount.name", "irsa-sa")
+}
+
 func assertVarValue(t *testing.T, chart *HelmChart, key, wantValue string) {
 	t.Helper()
 

+ 5 - 1
e2e/framework/framework.go

@@ -85,6 +85,8 @@ func New(baseName string) *Framework {
 // BeforeEach creates a namespace.
 func (f *Framework) BeforeEach() {
 	var err error
+	err = util.CleanupTerminatingE2ENamespaces(GinkgoT().Context(), f.CRClient)
+	Expect(err).ToNot(HaveOccurred())
 	f.Namespace, err = util.CreateKubeNamespace(f.BaseName, f.KubeClientSet)
 	log.Logf("created test namespace %s", f.Namespace.Name)
 	Expect(err).ToNot(HaveOccurred())
@@ -104,7 +106,9 @@ func (f *Framework) AfterEach() {
 	// reset addons to default once the run is done
 	f.Addons = []addon.Addon{}
 	log.Logf("deleting test namespace %s", f.Namespace.Name)
-	err := util.DeleteKubeNamespace(f.Namespace.Name, f.KubeClientSet)
+	err := util.ClearKnownNamespaceFinalizers(GinkgoT().Context(), f.CRClient, f.Namespace.Name)
+	Expect(err).NotTo(HaveOccurred())
+	err = util.DeleteKubeNamespace(f.Namespace.Name, f.KubeClientSet)
 	Expect(err).NotTo(HaveOccurred())
 }
 

+ 7 - 0
e2e/framework/testcase.go

@@ -42,6 +42,7 @@ type TestCase struct {
 	Secrets                 map[string]SecretEntry
 	ExpectedSecret          *v1.Secret
 	Prepare                 func(*TestCase, SecretStoreProvider)
+	Cleanup                 func()
 	ProviderOverride        SecretStoreProvider
 	AfterSync               func(SecretStoreProvider, *v1.Secret)
 	VerifyPushSecretOutcome func(ps *esv1alpha1.PushSecret, pushClient esv1.SecretsClient)
@@ -69,6 +70,12 @@ func TableFuncWithExternalSecret(f *Framework, prov SecretStoreProvider) func(..
 			tweak(tc)
 		}
 
+		defer func() {
+			if tc.Cleanup != nil {
+				tc.Cleanup()
+			}
+		}()
+
 		prov = prepareTestCase(tc, prov)
 
 		// create secrets & defer delete

+ 60 - 0
e2e/framework/util/util.go

@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	fluxhelm "github.com/fluxcd/helm-controller/api/v2"
@@ -79,6 +80,8 @@ func init() {
 const (
 	// How often to poll for conditions.
 	Poll = 2 * time.Second
+
+	e2eNamespacePrefix = "e2e-tests-"
 )
 
 // CreateKubeNamespace creates a new Kubernetes Namespace for a test.
@@ -97,6 +100,63 @@ func DeleteKubeNamespace(namespace string, kubeClientSet kubernetes.Interface) e
 	return kubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), namespace, metav1.DeleteOptions{})
 }
 
+func IsE2ETestNamespace(namespace string) bool {
+	return strings.HasPrefix(namespace, e2eNamespacePrefix)
+}
+
+func ClearKnownNamespaceFinalizers(ctx context.Context, c crclient.Client, namespace string) error {
+	var secretList v1.SecretList
+	if err := c.List(ctx, &secretList, crclient.InNamespace(namespace)); err != nil {
+		return err
+	}
+	for i := range secretList.Items {
+		if len(secretList.Items[i].Finalizers) == 0 {
+			continue
+		}
+		secret := secretList.Items[i].DeepCopy()
+		secret.Finalizers = nil
+		if err := c.Update(ctx, secret); err != nil {
+			return err
+		}
+	}
+
+	var pushSecretList esv1alpha1.PushSecretList
+	if err := c.List(ctx, &pushSecretList, crclient.InNamespace(namespace)); err != nil {
+		return err
+	}
+	for i := range pushSecretList.Items {
+		if len(pushSecretList.Items[i].Finalizers) == 0 {
+			continue
+		}
+		pushSecret := pushSecretList.Items[i].DeepCopy()
+		pushSecret.Finalizers = nil
+		if err := c.Update(ctx, pushSecret); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func CleanupTerminatingE2ENamespaces(ctx context.Context, c crclient.Client) error {
+	var namespaceList v1.NamespaceList
+	if err := c.List(ctx, &namespaceList); err != nil {
+		return err
+	}
+
+	for i := range namespaceList.Items {
+		namespace := namespaceList.Items[i]
+		if !IsE2ETestNamespace(namespace.Name) || namespace.DeletionTimestamp == nil {
+			continue
+		}
+		if err := ClearKnownNamespaceFinalizers(ctx, c, namespace.Name); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // WaitForKubeNamespaceNotExist will wait for the namespace with the given name
 // to not exist for up to 2 minutes.
 func WaitForKubeNamespaceNotExist(namespace string, kubeClientSet kubernetes.Interface) error {

+ 126 - 0
e2e/framework/util/util_test.go

@@ -0,0 +1,126 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package util
+
+import (
+	"context"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+func TestClearKnownNamespaceFinalizers(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "target-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+		&esv1alpha1.PushSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "push-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"pushsecret.externalsecrets.io/finalizer"},
+			},
+		},
+	).Build()
+
+	if err := ClearKnownNamespaceFinalizers(ctx, cl, "e2e-tests-demo-12345"); err != nil {
+		t.Fatalf("ClearKnownNamespaceFinalizers() error = %v", err)
+	}
+
+	var secret corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "target-secret", Namespace: "e2e-tests-demo-12345"}, &secret); err != nil {
+		t.Fatalf("Get(secret) error = %v", err)
+	}
+	if len(secret.Finalizers) != 0 {
+		t.Fatalf("expected secret finalizers to be cleared, got %v", secret.Finalizers)
+	}
+
+	var pushSecret esv1alpha1.PushSecret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "push-secret", Namespace: "e2e-tests-demo-12345"}, &pushSecret); err != nil {
+		t.Fatalf("Get(pushsecret) error = %v", err)
+	}
+	if len(pushSecret.Finalizers) != 0 {
+		t.Fatalf("expected pushsecret finalizers to be cleared, got %v", pushSecret.Finalizers)
+	}
+}
+
+func TestCleanupTerminatingE2ENamespaces(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	now := metav1.Now()
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+		&corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "e2e-tests-demo-12345",
+				Finalizers: []string{"kubernetes"},
+				DeletionTimestamp: &now,
+			},
+		},
+		&corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "plain-namespace",
+			},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "target-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "untouched-secret",
+				Namespace:  "plain-namespace",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+	).Build()
+
+	if err := CleanupTerminatingE2ENamespaces(ctx, cl); err != nil {
+		t.Fatalf("CleanupTerminatingE2ENamespaces() error = %v", err)
+	}
+
+	var cleaned corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "target-secret", Namespace: "e2e-tests-demo-12345"}, &cleaned); err != nil {
+		t.Fatalf("Get(cleaned) error = %v", err)
+	}
+	if len(cleaned.Finalizers) != 0 {
+		t.Fatalf("expected terminating e2e namespace secret finalizers to be cleared, got %v", cleaned.Finalizers)
+	}
+
+	var untouched corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "untouched-secret", Namespace: "plain-namespace"}, &untouched); err != nil {
+		t.Fatalf("Get(untouched) error = %v", err)
+	}
+	if len(untouched.Finalizers) != 1 {
+		t.Fatalf("expected non-e2e namespace secret finalizers to remain, got %v", untouched.Finalizers)
+	}
+}

+ 5 - 1
e2e/framework/v2/helpers.go

@@ -43,7 +43,11 @@ const (
 )
 
 func ProviderAddress(providerName string) string {
-	return fmt.Sprintf("provider-%s.%s.svc:8080", providerName, ProviderNamespace)
+	return ProviderAddressInNamespace(providerName, ProviderNamespace)
+}
+
+func ProviderAddressInNamespace(providerName, namespace string) string {
+	return fmt.Sprintf("provider-%s.%s.svc:8080", providerName, namespace)
 }
 
 func GetClusterCABundle(f *framework.Framework, namespace string) []byte {

+ 145 - 0
e2e/suites/provider/cases/aws/common_test.go

@@ -0,0 +1,145 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package common
+
+import (
+	"strings"
+	"testing"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func TestCredentialsSecretName(t *testing.T) {
+	t.Parallel()
+
+	if got := CredentialsSecretName("aws-config"); got != "aws-config-credentials" {
+		t.Fatalf("unexpected credentials secret name: %q", got)
+	}
+}
+
+func TestStaticCredentialsSecretDataPreservesSessionToken(t *testing.T) {
+	t.Parallel()
+
+	got := StaticCredentialsSecretData("kid", "sak", "st")
+	if got[StaticAccessKeyIDKey] != "kid" {
+		t.Fatalf("unexpected access key id: %q", got[StaticAccessKeyIDKey])
+	}
+	if got[StaticSecretAccessKeyKey] != "sak" {
+		t.Fatalf("unexpected secret access key: %q", got[StaticSecretAccessKeyKey])
+	}
+	if got[StaticSessionTokenKey] != "st" {
+		t.Fatalf("unexpected session token: %q", got[StaticSessionTokenKey])
+	}
+}
+
+func TestProviderConfigNamespaceForManifestScope(t *testing.T) {
+	t.Parallel()
+
+	if got := ProviderConfigNamespace(esv1.AuthenticationScopeManifestNamespace, "provider-ns", "workload-ns"); got != "workload-ns" {
+		t.Fatalf("expected workload namespace, got %q", got)
+	}
+}
+
+func TestProviderConfigNamespaceForProviderScope(t *testing.T) {
+	t.Parallel()
+
+	if got := ProviderConfigNamespace(esv1.AuthenticationScopeProviderNamespace, "provider-ns", "workload-ns"); got != "provider-ns" {
+		t.Fatalf("expected provider namespace, got %q", got)
+	}
+}
+
+func TestProviderReferenceNamespaceForManifestScope(t *testing.T) {
+	t.Parallel()
+
+	if got := ProviderReferenceNamespace(esv1.AuthenticationScopeManifestNamespace, "provider-ns"); got != "" {
+		t.Fatalf("expected empty provider reference namespace, got %q", got)
+	}
+}
+
+func TestProviderReferenceNamespaceForProviderScope(t *testing.T) {
+	t.Parallel()
+
+	if got := ProviderReferenceNamespace(esv1.AuthenticationScopeProviderNamespace, "provider-ns"); got != "provider-ns" {
+		t.Fatalf("expected provider namespace, got %q", got)
+	}
+}
+
+func TestNewV2ClusterProviderScenarioManifestScope(t *testing.T) {
+	t.Parallel()
+
+	called := false
+	got := NewV2ClusterProviderScenario("workload-ns", "case", esv1.AuthenticationScopeManifestNamespace, func(prefix string) string {
+		called = true
+		return prefix + "-provider"
+	})
+	if called {
+		t.Fatal("expected provider namespace factory to be unused for manifest scope")
+	}
+	if got.ConfigName != "case-config" {
+		t.Fatalf("unexpected config name: %q", got.ConfigName)
+	}
+	if got.ConfigNamespace != "workload-ns" {
+		t.Fatalf("unexpected config namespace: %q", got.ConfigNamespace)
+	}
+	if got.ProviderNamespace != "workload-ns" {
+		t.Fatalf("unexpected provider namespace: %q", got.ProviderNamespace)
+	}
+	if got.ProviderRefNamespace != "" {
+		t.Fatalf("expected empty provider reference namespace, got %q", got.ProviderRefNamespace)
+	}
+	if got.WorkloadNamespace != "workload-ns" {
+		t.Fatalf("unexpected workload namespace: %q", got.WorkloadNamespace)
+	}
+	if got.NamePrefix != "workload-ns-case" {
+		t.Fatalf("unexpected name prefix: %q", got.NamePrefix)
+	}
+}
+
+func TestNewV2ClusterProviderScenarioProviderScope(t *testing.T) {
+	t.Parallel()
+
+	var gotPrefix string
+	got := NewV2ClusterProviderScenario("workload-ns", "case", esv1.AuthenticationScopeProviderNamespace, func(prefix string) string {
+		gotPrefix = prefix
+		return "provider-ns"
+	})
+	if gotPrefix != "case-provider" {
+		t.Fatalf("unexpected provider namespace prefix: %q", gotPrefix)
+	}
+	if got.ConfigNamespace != "provider-ns" {
+		t.Fatalf("unexpected config namespace: %q", got.ConfigNamespace)
+	}
+	if got.ProviderNamespace != "provider-ns" {
+		t.Fatalf("unexpected provider namespace: %q", got.ProviderNamespace)
+	}
+	if got.ProviderRefNamespace != "provider-ns" {
+		t.Fatalf("unexpected provider reference namespace: %q", got.ProviderRefNamespace)
+	}
+}
+
+func TestPushSecretMetadataWithRemoteNamespace(t *testing.T) {
+	t.Parallel()
+
+	got := PushSecretMetadataWithRemoteNamespace("target-ns")
+	if got == nil {
+		t.Fatal("expected metadata payload")
+	}
+	raw := string(got.Raw)
+	if !strings.Contains(raw, `"remoteNamespace":"target-ns"`) {
+		t.Fatalf("expected remote namespace in metadata, got %q", raw)
+	}
+}

+ 109 - 0
e2e/suites/provider/cases/aws/parameterstore/clusterprovider_v2.go

@@ -0,0 +1,109 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+var _ = Describe("[aws] v2 cluster provider", Label("aws", "parameterstore", "v2", "cluster-provider"), func() {
+	f := framework.New("eso-aws-ps-v2-clusterprovider")
+	prov := NewProviderV2(f)
+	harness := newAWSClusterProviderExternalSecretHarness(f, prov)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("cluster provider external secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(common.ClusterProviderManifestNamespace(f, harness)),
+		Entry(common.ClusterProviderProviderNamespace(f, harness)),
+		Entry(common.ClusterProviderDeniedByConditions(f, harness)),
+	)
+})
+
+type awsClusterProviderScenario struct {
+	common               awscommon.V2ClusterProviderScenario
+	access               awsV2AccessConfig
+	authScope            esv1.AuthenticationScope
+	backend              *parameterStoreBackend
+	f                    *framework.Framework
+}
+
+func newAWSClusterProviderScenario(f *framework.Framework, prefix string, authScope esv1.AuthenticationScope, access awsV2AccessConfig, backend *parameterStoreBackend) *awsClusterProviderScenario {
+	shared := awscommon.NewV2ClusterProviderScenario(f.Namespace.Name, prefix, authScope, func(prefix string) string {
+		return common.CreateProviderCaseNamespace(f, prefix, defaultV2PollInterval)
+	})
+	s := &awsClusterProviderScenario{
+		common:    shared,
+		access:    access,
+		authScope: authScope,
+		backend:   backend,
+		f:         f,
+	}
+	createParameterStoreV2Config(s.f, s.common.ConfigNamespace, s.common.ConfigName, s.access)
+	return s
+}
+
+func (s *awsClusterProviderScenario) createClusterProvider(conditions []esv1.ClusterSecretStoreCondition) string {
+	clusterProviderName := s.common.ClusterProviderName()
+	frameworkv2.CreateClusterProviderConnection(
+		s.f,
+		clusterProviderName,
+		frameworkv2.ProviderAddress("aws"),
+		awsProviderAPIVersion,
+		awsv2alpha1.ParameterStoreKind,
+		s.common.ConfigName,
+		s.common.ProviderRefNamespace,
+		s.common.AuthScope,
+		conditions,
+	)
+	return clusterProviderName
+}
+
+func (s *awsClusterProviderScenario) CreateSecret(key string, val framework.SecretEntry) {
+	s.backend.CreateSecret(key, val)
+}
+
+func (s *awsClusterProviderScenario) DeleteSecret(key string) {
+	s.backend.DeleteSecret(key)
+}
+
+func newAWSClusterProviderExternalSecretHarness(f *framework.Framework, prov *ProviderV2) common.ClusterProviderExternalSecretHarness {
+	return common.ClusterProviderExternalSecretHarness{
+		Prepare: func(_ *framework.TestCase, cfg common.ClusterProviderConfig) *common.ClusterProviderExternalSecretRuntime {
+			s := newAWSClusterProviderScenario(f, cfg.Name, cfg.AuthScope, prov.access, prov.backend)
+			clusterProviderName := s.createClusterProvider(cfg.Conditions)
+			frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+			return &common.ClusterProviderExternalSecretRuntime{
+				ClusterProviderName: clusterProviderName,
+				Provider:            s,
+			}
+		},
+	}
+}

+ 343 - 0
e2e/suites/provider/cases/aws/parameterstore/provider_support_v2.go

@@ -0,0 +1,343 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/ssm"
+	ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmetav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+const (
+	awsProviderAPIVersion = "provider.external-secrets.io/v2alpha1"
+	defaultV2WaitTimeout  = 60 * time.Second
+	defaultV2PollInterval = 2 * time.Second
+)
+
+type awsV2AccessConfig struct {
+	KID    string
+	SAK    string
+	ST     string
+	Region string
+}
+
+type parameterStoreBackend struct {
+	access    awsV2AccessConfig
+	client    *ssm.Client
+	clientErr error
+	clientOnce sync.Once
+}
+
+func loadAWSV2AccessConfigFromEnv() awsV2AccessConfig {
+	return awsV2AccessConfig{
+		KID:    os.Getenv("AWS_ACCESS_KEY_ID"),
+		SAK:    os.Getenv("AWS_SECRET_ACCESS_KEY"),
+		ST:     os.Getenv("AWS_SESSION_TOKEN"),
+		Region: os.Getenv("AWS_REGION"),
+	}
+}
+
+func (c awsV2AccessConfig) missingStaticCredentials() []string {
+	var missing []string
+	if c.KID == "" {
+		missing = append(missing, "AWS_ACCESS_KEY_ID")
+	}
+	if c.SAK == "" {
+		missing = append(missing, "AWS_SECRET_ACCESS_KEY")
+	}
+	if c.Region == "" {
+		missing = append(missing, "AWS_REGION")
+	}
+	return missing
+}
+
+func skipIfAWSV2StaticCredentialsMissing(access awsV2AccessConfig) {
+	if missing := access.missingStaticCredentials(); len(missing) > 0 {
+		Skip("missing AWS e2e credentials: " + strings.Join(missing, ", "))
+	}
+}
+
+func staticAWSV2Auth(secretName string) esv1.AWSAuth {
+	return esv1.AWSAuth{
+		SecretRef: &esv1.AWSAuthSecretRef{
+			AccessKeyID: esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticAccessKeyIDKey,
+			},
+			SecretAccessKey: esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticSecretAccessKeyKey,
+			},
+			SessionToken: &esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticSessionTokenKey,
+			},
+		},
+	}
+}
+
+func newStaticCredentialsSecret(namespace, name string, access awsV2AccessConfig) *corev1.Secret {
+	return &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		StringData: awscommon.StaticCredentialsSecretData(access.KID, access.SAK, access.ST),
+	}
+}
+
+func createStaticCredentialsSecret(f *framework.Framework, namespace, name string, access awsV2AccessConfig) {
+	Expect(f.CRClient.Create(GinkgoT().Context(), newStaticCredentialsSecret(namespace, name, access))).To(Succeed())
+}
+
+func newParameterStoreV2Config(namespace, name string, access awsV2AccessConfig) *awsv2alpha1.ParameterStore {
+	return &awsv2alpha1.ParameterStore{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: awsv2alpha1.GroupVersion.String(),
+			Kind:       awsv2alpha1.ParameterStoreKind,
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: awsv2alpha1.ParameterStoreSpec{
+			Region: access.Region,
+			Auth:   staticAWSV2Auth(awscommon.CredentialsSecretName(name)),
+		},
+	}
+}
+
+func createParameterStoreV2Config(f *framework.Framework, namespace, name string, access awsV2AccessConfig) *awsv2alpha1.ParameterStore {
+	createStaticCredentialsSecret(f, namespace, awscommon.CredentialsSecretName(name), access)
+	cfg := newParameterStoreV2Config(namespace, name, access)
+	Expect(f.CRClient.Create(GinkgoT().Context(), cfg)).To(Succeed())
+	return cfg
+}
+
+func loadParameterStoreAWSConfig(access awsV2AccessConfig) (aws.Config, error) {
+	loadOptions := []func(*config.LoadOptions) error{
+		config.WithRegion(access.Region),
+	}
+	if access.KID != "" || access.SAK != "" || access.ST != "" {
+		loadOptions = append(loadOptions, config.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(access.KID, access.SAK, access.ST),
+		))
+	}
+	return config.LoadDefaultConfig(context.Background(), loadOptions...)
+}
+
+func newParameterStoreBackend(access awsV2AccessConfig) *parameterStoreBackend {
+	return &parameterStoreBackend{access: access}
+}
+
+func (b *parameterStoreBackend) ensureClient() {
+	b.clientOnce.Do(func() {
+		cfg, err := loadParameterStoreAWSConfig(b.access)
+		if err != nil {
+			b.clientErr = err
+			return
+		}
+		b.client = ssm.NewFromConfig(cfg)
+	})
+
+	Expect(b.clientErr).ToNot(HaveOccurred())
+	Expect(b.client).NotTo(BeNil())
+}
+
+func (b *parameterStoreBackend) CreateSecret(key string, val framework.SecretEntry) {
+	b.ensureClient()
+
+	psTags := make([]ssmtypes.Tag, 0, len(val.Tags))
+	for tagKey, tagValue := range val.Tags {
+		psTags = append(psTags, ssmtypes.Tag{
+			Key:   aws.String(tagKey),
+			Value: aws.String(tagValue),
+		})
+	}
+
+	overwrite := len(psTags) == 0
+	_, err := b.client.PutParameter(GinkgoT().Context(), &ssm.PutParameterInput{
+		Name:      aws.String(key),
+		Value:     aws.String(val.Value),
+		DataType:  aws.String("text"),
+		Type:      ssmtypes.ParameterTypeString,
+		Overwrite: aws.Bool(overwrite),
+		Tags:      psTags,
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (b *parameterStoreBackend) DeleteSecret(key string) {
+	b.ensureClient()
+
+	_, err := b.client.DeleteParameter(GinkgoT().Context(), &ssm.DeleteParameterInput{
+		Name: aws.String(key),
+	})
+	var parameterNotFound *ssmtypes.ParameterNotFound
+	var resourceNotFound *ssmtypes.ResourceNotFoundException
+	if errors.As(err, &parameterNotFound) || errors.As(err, &resourceNotFound) {
+		return
+	}
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (b *parameterStoreBackend) WaitForSecretValue(name, expectedValue string) {
+	b.ensureClient()
+
+	Eventually(func(g Gomega) {
+		out, err := b.client.GetParameter(GinkgoT().Context(), &ssm.GetParameterInput{
+			Name:           aws.String(name),
+			WithDecryption: aws.Bool(true),
+		})
+		g.Expect(err).NotTo(HaveOccurred())
+		g.Expect(out.Parameter).NotTo(BeNil())
+		g.Expect(aws.ToString(out.Parameter.Value)).To(Equal(expectedValue))
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(Succeed())
+}
+
+func (b *parameterStoreBackend) ExpectSecretAbsent(name string) {
+	b.ensureClient()
+
+	Eventually(func() bool {
+		_, err := b.client.GetParameter(GinkgoT().Context(), &ssm.GetParameterInput{
+			Name:           aws.String(name),
+			WithDecryption: aws.Bool(true),
+		})
+		return parameterStoreReadErrorIndicatesAbsence(err)
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(BeTrue(), fmt.Sprintf("expected AWS parameter %q to be absent", name))
+}
+
+func parameterStoreReadErrorIndicatesAbsence(err error) bool {
+	if err == nil {
+		return false
+	}
+	var parameterNotFound *ssmtypes.ParameterNotFound
+	var resourceNotFound *ssmtypes.ResourceNotFoundException
+	return errors.As(err, &parameterNotFound) || errors.As(err, &resourceNotFound)
+}
+
+type ProviderV2 struct {
+	access    awsV2AccessConfig
+	backend   *parameterStoreBackend
+	framework *framework.Framework
+}
+
+func NewProviderV2(f *framework.Framework) *ProviderV2 {
+	access := loadAWSV2AccessConfigFromEnv()
+	f.MakeRemoteRefKey = func(base string) string {
+		if f.Namespace == nil {
+			return parameterStoreRemoteRefKey(base, "")
+		}
+		return parameterStoreRemoteRefKey(base, f.Namespace.Name)
+	}
+
+	prov := &ProviderV2{
+		access:    access,
+		backend:   newParameterStoreBackend(access),
+		framework: f,
+	}
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			return
+		}
+		skipIfAWSV2StaticCredentialsMissing(access)
+	})
+
+	return prov
+}
+
+func parameterStoreRemoteRefKey(base, namespace string) string {
+	base = strings.Trim(base, "/")
+	if namespace == "" {
+		return "/e2e/" + base
+	}
+	return fmt.Sprintf("/e2e/%s/%s", namespace, base)
+}
+
+func (p *ProviderV2) CreateSecret(key string, val framework.SecretEntry) {
+	p.backend.CreateSecret(key, val)
+}
+
+func (p *ProviderV2) DeleteSecret(key string) {
+	p.backend.DeleteSecret(key)
+}
+
+func useV2StaticAuth(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProvider()
+	}
+}
+
+func (p *ProviderV2) prepareNamespacedProvider() func(*framework.TestCase, framework.SecretStoreProvider) {
+	return func(_ *framework.TestCase, _ framework.SecretStoreProvider) {
+		configName := p.providerConfigName()
+		createParameterStoreV2Config(p.framework, p.framework.Namespace.Name, configName, p.access)
+		frameworkv2.CreateProviderConnection(
+			p.framework,
+			p.framework.Namespace.Name,
+			p.framework.Namespace.Name,
+			frameworkv2.ProviderAddress("aws"),
+			awsProviderAPIVersion,
+			awsv2alpha1.ParameterStoreKind,
+			configName,
+			p.framework.Namespace.Name,
+		)
+		frameworkv2.WaitForProviderConnectionReady(p.framework, p.framework.Namespace.Name, p.framework.Namespace.Name, defaultV2WaitTimeout)
+	}
+}
+
+func (p *ProviderV2) providerConfigName() string {
+	return fmt.Sprintf("%s-parameterstore", p.framework.Namespace.Name)
+}
+
+func createParameterStoreV2ProviderConnection(f *framework.Framework, namespace, name, providerName, providerNamespace string) {
+	frameworkv2.CreateProviderConnection(
+		f,
+		namespace,
+		name,
+		frameworkv2.ProviderAddress("aws"),
+		awsProviderAPIVersion,
+		awsv2alpha1.ParameterStoreKind,
+		providerName,
+		providerNamespace,
+	)
+	log.Logf("created ParameterStore Provider connection: %s/%s", namespace, name)
+}

+ 56 - 0
e2e/suites/provider/cases/aws/parameterstore/provider_support_v2_test.go

@@ -0,0 +1,56 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"strings"
+	"testing"
+
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+func TestNewParameterStoreV2ConfigUsesStaticSessionTokenSelector(t *testing.T) {
+	t.Parallel()
+
+	cfg := newParameterStoreV2Config("ns", "ps-static", awsV2AccessConfig{
+		Region: "eu-central-1",
+	})
+	if cfg.TypeMeta.Kind != awsv2alpha1.ParameterStoreKind {
+		t.Fatalf("expected kind %q, got %q", awsv2alpha1.ParameterStoreKind, cfg.TypeMeta.Kind)
+	}
+	if cfg.Spec.Auth.SecretRef == nil || cfg.Spec.Auth.SecretRef.SessionToken == nil {
+		t.Fatal("expected session token selector to be configured for static auth")
+	}
+	if cfg.Spec.Auth.SecretRef.SessionToken.Name != "ps-static-credentials" || cfg.Spec.Auth.SecretRef.SessionToken.Key != "st" {
+		t.Fatalf("unexpected session token selector: %+v", cfg.Spec.Auth.SecretRef.SessionToken)
+	}
+}
+
+func TestParameterStoreRemoteRefKeyAvoidsReservedPrefixes(t *testing.T) {
+	t.Parallel()
+
+	got := parameterStoreRemoteRefKey("aws-v2-ps-refresh-remote", "e2e-tests-eso-aws-ps-v2-6s27x")
+	if !strings.HasPrefix(got, "/e2e/") {
+		t.Fatalf("expected /e2e/ prefix, got %q", got)
+	}
+	if strings.HasPrefix(strings.TrimPrefix(got, "/"), "aws") || strings.HasPrefix(strings.TrimPrefix(got, "/"), "ssm") {
+		t.Fatalf("expected non-reserved parameter prefix, got %q", got)
+	}
+	if !strings.Contains(got, "aws-v2-ps-refresh-remote") {
+		t.Fatalf("expected remote key to retain base name, got %q", got)
+	}
+}

+ 120 - 0
e2e/suites/provider/cases/aws/parameterstore/provider_v2.go

@@ -0,0 +1,120 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"fmt"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	corev1 "k8s.io/api/core/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+var _ = Describe("[aws] v2 namespaced provider", Label("aws", "parameterstore", "v2", "namespaced-provider"), func() {
+	f := framework.New("eso-aws-ps-v2")
+	prov := NewProviderV2(f)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("namespaced provider",
+		framework.TableFuncWithExternalSecret(f, prov),
+		framework.Compose(withStaticAuth, f, func(_ *framework.Framework) (string, func(*framework.TestCase)) {
+			return common.NamespacedProviderSync(f, common.NamespacedProviderSyncConfig{
+				Description:        "[aws] should sync an ExternalSecret through a namespaced ParameterStore Provider using static credentials",
+				ExternalSecretName: "aws-v2-ps-static-es",
+				TargetSecretName:   "aws-v2-ps-static-target",
+				RemoteKey:          f.MakeRemoteRefKey("aws-v2-ps-static-remote"),
+				RemoteSecretValue:  "aws-v2-ps-static-value",
+				SecretKey:          "value",
+				ExpectedValue:      "aws-v2-ps-static-value",
+			})
+		}, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, func(_ *framework.Framework) (string, func(*framework.TestCase)) {
+			return common.NamespacedProviderRefresh(f, common.NamespacedProviderRefreshConfig{
+				Description:         "[aws] should refresh synced ParameterStore secrets after the remote parameter changes",
+				ExternalSecretName:  "aws-v2-ps-refresh-es",
+				TargetSecretName:    "aws-v2-ps-refresh-target",
+				RemoteKey:           f.MakeRemoteRefKey("aws-v2-ps-refresh-remote"),
+				InitialSecretValue:  "aws-v2-ps-initial",
+				UpdatedSecretValue:  "aws-v2-ps-updated",
+				SecretKey:           "value",
+				InitialExpectedData: "aws-v2-ps-initial",
+				UpdatedExpectedData: "aws-v2-ps-updated",
+				RefreshInterval:     10 * time.Second,
+				WaitTimeout:         30 * time.Second,
+			})
+		}, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, FindByName, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, FindByTag, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, versionedParameterV2(prov), useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, common.StatusNotUpdatedAfterSuccessfulSync, useV2StaticAuth(prov)),
+	)
+})
+
+func versionedParameterV2(prov framework.SecretStoreProvider) func(*framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return "[common] should read versioned secrets", func(tc *framework.TestCase) {
+			secretKey := fmt.Sprintf("/e2e/versioned/%s/%s", f.Namespace.Name, "one")
+			versions := []int{1, 2, 3, 4, 5}
+
+			tc.ExpectedSecret = commonVersionedExpectedSecret(versions)
+			tc.ExternalSecret.Spec.Data = commonVersionedExternalSecretData(secretKey, versions)
+			tc.Cleanup = func() {
+				prov.DeleteSecret(secretKey)
+			}
+
+			for _, version := range versions {
+				prov.CreateSecret(secretKey, framework.SecretEntry{
+					Value: fmt.Sprintf("value%d", version),
+				})
+			}
+		}
+	}
+}
+
+func commonVersionedExpectedSecret(versions []int) *corev1.Secret {
+	data := make(map[string][]byte, len(versions))
+	for _, version := range versions {
+		data[fmt.Sprintf("v%d", version)] = []byte(fmt.Sprintf("value%d", version))
+	}
+	return &corev1.Secret{
+		Type: corev1.SecretTypeOpaque,
+		Data: data,
+	}
+}
+
+func commonVersionedExternalSecretData(secretKey string, versions []int) []esapi.ExternalSecretData {
+	data := make([]esapi.ExternalSecretData, 0, len(versions))
+	for _, version := range versions {
+		data = append(data, esapi.ExternalSecretData{
+			SecretKey: fmt.Sprintf("v%d", version),
+			RemoteRef: esapi.ExternalSecretDataRemoteRef{
+				Key:     secretKey,
+				Version: fmt.Sprintf("%d", version),
+			},
+		})
+	}
+	return data
+}

+ 72 - 0
e2e/suites/provider/cases/aws/parameterstore/provider_v2_test.go

@@ -0,0 +1,72 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+type recordingSecretStoreProvider struct {
+	created []string
+	deleted []string
+}
+
+func (p *recordingSecretStoreProvider) CreateSecret(key string, _ framework.SecretEntry) {
+	p.created = append(p.created, key)
+}
+
+func (p *recordingSecretStoreProvider) DeleteSecret(key string) {
+	p.deleted = append(p.deleted, key)
+}
+
+func TestVersionedParameterV2RegistersCleanupWithoutDeletingDuringSetup(t *testing.T) {
+	fakeProvider := &recordingSecretStoreProvider{}
+
+	f := &framework.Framework{
+		Namespace: &corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{Name: "test-ns"},
+		},
+	}
+	_, tweak := versionedParameterV2(fakeProvider)(f)
+	tc := &framework.TestCase{
+		ExternalSecret: &esapi.ExternalSecret{},
+	}
+
+	tweak(tc)
+
+	if got, want := len(fakeProvider.created), 5; got != want {
+		t.Fatalf("expected %d created versions, got %d", want, got)
+	}
+	if got := len(fakeProvider.deleted); got != 0 {
+		t.Fatalf("expected no deletes during setup, got %d", got)
+	}
+	if tc.Cleanup == nil {
+		t.Fatalf("expected cleanup callback to be registered")
+	}
+
+	tc.Cleanup()
+
+	if got, want := len(fakeProvider.deleted), 1; got != want {
+		t.Fatalf("expected %d delete after cleanup, got %d", want, got)
+	}
+}

+ 152 - 0
e2e/suites/provider/cases/aws/parameterstore/push_v2.go

@@ -0,0 +1,152 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+var _ = Describe("[aws] v2 push secret", Label("aws", "parameterstore", "v2", "push-secret"), func() {
+	f := framework.New("eso-aws-ps-v2-push")
+	prov := NewProviderV2(f)
+	harness := newAWSClusterProviderPushHarness(f, prov)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("push secret",
+		framework.TableFuncWithPushSecret(f, prov, nil),
+		Entry(awsPushSecretImplicitProviderKind(f, prov)),
+		Entry(awsPushSecretRejectsNamespacedRemoteNamespaceOverride(f, prov)),
+		Entry(common.ClusterProviderPushManifestNamespace(f, harness)),
+		Entry(common.ClusterProviderPushProviderNamespace(f, harness)),
+		Entry(common.ClusterProviderPushDeniedByConditions(f, harness)),
+	)
+})
+
+func newAWSClusterProviderPushHarness(f *framework.Framework, prov *ProviderV2) common.ClusterProviderPushHarness {
+	return common.ClusterProviderPushHarness{
+		Prepare: func(_ *framework.TestCase, cfg common.ClusterProviderConfig) *common.ClusterProviderPushRuntime {
+			s := newAWSClusterProviderScenario(f, cfg.Name, cfg.AuthScope, prov.access, prov.backend)
+			clusterProviderName := s.createClusterProvider(cfg.Conditions)
+			frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+			return &common.ClusterProviderPushRuntime{
+				ClusterProviderName:    clusterProviderName,
+				DefaultRemoteNamespace: "",
+				WaitForRemoteSecretValue: func(_, name, _ , expectedValue string) {
+					s.backend.WaitForSecretValue(name, expectedValue)
+				},
+				ExpectNoRemoteSecret: func(_, name string) {
+					s.backend.ExpectSecretAbsent(name)
+				},
+			}
+		},
+	}
+}
+
+func awsPushSecretImplicitProviderKind(f *framework.Framework, prov *ProviderV2) (string, func(*framework.TestCase)) {
+	return "[aws] should support namespaced Provider refs when push kind is omitted", func(tc *framework.TestCase) {
+		remoteKey := f.MakeRemoteRefKey("aws-v2-ps-push-implicit")
+		tc.Prepare = prov.prepareNamespacedProvider()
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "aws-v2-ps-push-implicit-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("value1"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "aws-v2-ps-push-implicit"
+		tc.PushSecret.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyDelete
+		tc.PushSecret.Spec.SecretStoreRefs[0].Kind = ""
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "value",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: remoteKey,
+					Property:  "value",
+				},
+			},
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			awscommon.WaitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+			prov.backend.WaitForSecretValue(remoteKey, "value1")
+
+			Expect(tc.Framework.CRClient.Delete(context.Background(), ps)).To(Succeed())
+			prov.backend.ExpectSecretAbsent(remoteKey)
+		}
+	}
+}
+
+func awsPushSecretRejectsNamespacedRemoteNamespaceOverride(f *framework.Framework, prov *ProviderV2) (string, func(*framework.TestCase)) {
+	return "[aws] should reject remote namespace overrides when pushing through a namespaced Provider", func(tc *framework.TestCase) {
+		remoteKey := f.MakeRemoteRefKey("aws-v2-ps-push-override")
+		tc.Prepare = prov.prepareNamespacedProvider()
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "aws-v2-ps-push-override-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("should-not-push"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "aws-v2-ps-push-override"
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "value",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: remoteKey,
+					Property:  "value",
+				},
+			},
+			Metadata: awscommon.PushSecretMetadataWithRemoteNamespace("ignored-aws-namespace"),
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			awscommon.WaitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+			prov.backend.ExpectSecretAbsent(remoteKey)
+			awscommon.ExpectPushSecretEventMessage(tc.Framework, ps.Namespace, ps.Name, `unknown field "remoteNamespace"`)
+		}
+	}
+}

+ 109 - 0
e2e/suites/provider/cases/aws/secretsmanager/clusterprovider_v2.go

@@ -0,0 +1,109 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+var _ = Describe("[aws] v2 cluster provider", Label("aws", "secretsmanager", "v2", "cluster-provider"), func() {
+	f := framework.New("eso-aws-sm-v2-clusterprovider")
+	prov := NewProviderV2(f)
+	harness := newAWSClusterProviderExternalSecretHarness(f, prov)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("cluster provider external secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(common.ClusterProviderManifestNamespace(f, harness)),
+		Entry(common.ClusterProviderProviderNamespace(f, harness)),
+		Entry(common.ClusterProviderDeniedByConditions(f, harness)),
+	)
+})
+
+type awsClusterProviderScenario struct {
+	common               awscommon.V2ClusterProviderScenario
+	access               awsAccessConfig
+	authScope            esv1.AuthenticationScope
+	backend              *secretsManagerBackend
+	f                    *framework.Framework
+}
+
+func newAWSClusterProviderScenario(f *framework.Framework, prefix string, authScope esv1.AuthenticationScope, access awsAccessConfig, backend *secretsManagerBackend) *awsClusterProviderScenario {
+	shared := awscommon.NewV2ClusterProviderScenario(f.Namespace.Name, prefix, authScope, func(prefix string) string {
+		return common.CreateProviderCaseNamespace(f, prefix, defaultV2PollInterval)
+	})
+	s := &awsClusterProviderScenario{
+		common:    shared,
+		access:    access,
+		authScope: authScope,
+		backend:   backend,
+		f:         f,
+	}
+	createSecretsManagerV2Config(s.f, s.common.ConfigNamespace, s.common.ConfigName, s.access, awsAuthProfileStatic)
+	return s
+}
+
+func (s *awsClusterProviderScenario) createClusterProvider(conditions []esv1.ClusterSecretStoreCondition) string {
+	clusterProviderName := s.common.ClusterProviderName()
+	frameworkv2.CreateClusterProviderConnection(
+		s.f,
+		clusterProviderName,
+		frameworkv2.ProviderAddress("aws"),
+		awsProviderAPIVersion,
+		awsv2alpha1.SecretsManagerKind,
+		s.common.ConfigName,
+		s.common.ProviderRefNamespace,
+		s.common.AuthScope,
+		conditions,
+	)
+	return clusterProviderName
+}
+
+func (s *awsClusterProviderScenario) CreateSecret(key string, val framework.SecretEntry) {
+	s.backend.CreateSecret(key, val)
+}
+
+func (s *awsClusterProviderScenario) DeleteSecret(key string) {
+	s.backend.DeleteSecret(key)
+}
+
+func newAWSClusterProviderExternalSecretHarness(f *framework.Framework, prov *ProviderV2) common.ClusterProviderExternalSecretHarness {
+	return common.ClusterProviderExternalSecretHarness{
+		Prepare: func(_ *framework.TestCase, cfg common.ClusterProviderConfig) *common.ClusterProviderExternalSecretRuntime {
+			s := newAWSClusterProviderScenario(f, cfg.Name, cfg.AuthScope, prov.access, prov.backend)
+			clusterProviderName := s.createClusterProvider(cfg.Conditions)
+			frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+			return &common.ClusterProviderExternalSecretRuntime{
+				ClusterProviderName: clusterProviderName,
+				Provider:            s,
+			}
+		},
+	}
+}

+ 15 - 63
e2e/suites/provider/cases/aws/secretsmanager/provider.go

@@ -17,17 +17,6 @@ limitations under the License.
 package aws
 
 import (
-	"context"
-	"errors"
-	"os"
-	"time"
-
-	"github.com/aws/aws-sdk-go-v2/aws"
-	"github.com/aws/aws-sdk-go-v2/config"
-	"github.com/aws/aws-sdk-go-v2/credentials"
-	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
-	secretsmanagertypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
-
 	//nolint
 	. "github.com/onsi/ginkgo/v2"
 
@@ -47,26 +36,30 @@ type Provider struct {
 	ServiceAccountName      string
 	ServiceAccountNamespace string
 
+	backend   *secretsManagerBackend
 	region    string
-	client    *secretsmanager.Client
 	framework *framework.Framework
 }
 
 func NewProvider(f *framework.Framework, kid, sak, st, region, saName, saNamespace string) *Provider {
+	access := awsAccessConfig{
+		KID:         kid,
+		SAK:         sak,
+		ST:          st,
+		Region:      region,
+		SAName:      saName,
+		SANamespace: saNamespace,
+	}
 	prov := &Provider{
 		ServiceAccountName:      saName,
 		ServiceAccountNamespace: saNamespace,
+		backend:                 newSecretsManagerBackend(f, access),
 		region:                  region,
 		framework:               f,
 	}
 
-	BeforeAll(func() {
-		config, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(kid, sak, st)))
-		Expect(err).ToNot(HaveOccurred())
-		prov.client = secretsmanager.NewFromConfig(config)
-	})
-
 	BeforeEach(func() {
+		skipIfAWSStaticCredentialsMissing(access)
 		awscommon.SetupStaticStore(f, awscommon.AccessOpts{KID: kid, SAK: sak, ST: st, Region: region}, esv1.AWSServiceSecretsManager)
 		awscommon.SetupExternalIDStore(
 			f,
@@ -95,61 +88,20 @@ func NewProvider(f *framework.Framework, kid, sak, st, region, saName, saNamespa
 }
 
 func NewFromEnv(f *framework.Framework) *Provider {
-	kid := os.Getenv("AWS_ACCESS_KEY_ID")
-	sak := os.Getenv("AWS_SECRET_ACCESS_KEY")
-	st := os.Getenv("AWS_SESSION_TOKEN")
-	region := os.Getenv("AWS_REGION")
-	saName := os.Getenv("AWS_SA_NAME")
-	saNamespace := os.Getenv("AWS_SA_NAMESPACE")
-	return NewProvider(f, kid, sak, st, region, saName, saNamespace)
+	access := loadAWSAccessConfigFromEnv()
+	return NewProvider(f, access.KID, access.SAK, access.ST, access.Region, access.SAName, access.SANamespace)
 }
 
 // CreateSecret creates a secret at the provider.
 func (s *Provider) CreateSecret(key string, val framework.SecretEntry) {
-	smTags := make([]secretsmanagertypes.Tag, 0)
-	for k, v := range val.Tags {
-		smTags = append(smTags, secretsmanagertypes.Tag{
-			Key:   aws.String(k),
-			Value: aws.String(v),
-		})
-	}
-
-	// we re-use some secret names throughout our test suite
-	// due to the fact that there is a short delay before the secret is actually deleted
-	// we have to retry creating the secret
-	attempts := 20
-	for {
-		log.Logf("creating secret %s / attempts left: %d", key, attempts)
-		_, err := s.client.CreateSecret(GinkgoT().Context(), &secretsmanager.CreateSecretInput{
-			Name:         aws.String(key),
-			SecretString: aws.String(val.Value),
-			Tags:         smTags,
-		})
-		if err == nil {
-			return
-		}
-		attempts--
-		if attempts < 0 {
-			Fail("unable to create secret: " + err.Error())
-		}
-		<-time.After(time.Second * 5)
-	}
+	s.backend.CreateSecret(key, val)
 }
 
 // DeleteSecret deletes a secret at the provider.
 // There may be a short delay between calling this function
 // and the removal of the secret on the provider side.
 func (s *Provider) DeleteSecret(key string) {
-	log.Logf("deleting secret %s", key)
-	_, err := s.client.DeleteSecret(GinkgoT().Context(), &secretsmanager.DeleteSecretInput{
-		SecretId:                   aws.String(key),
-		ForceDeleteWithoutRecovery: aws.Bool(true),
-	})
-	var nf *secretsmanagertypes.ResourceNotFoundException
-	if errors.As(err, &nf) {
-		return
-	}
-	Expect(err).ToNot(HaveOccurred())
+	s.backend.DeleteSecret(key)
 }
 
 // MountedIRSAStore is a SecretStore without auth config

+ 484 - 0
e2e/suites/provider/cases/aws/secretsmanager/provider_support.go

@@ -0,0 +1,484 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	awssm "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
+	secretsmanagertypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
+	"github.com/aws/aws-sdk-go-v2/service/sts"
+	ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmetav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+const (
+	awsProviderAPIVersion = "provider.external-secrets.io/v2alpha1"
+	defaultV2WaitTimeout  = 60 * time.Second
+	defaultV2PollInterval = 2 * time.Second
+)
+
+const (
+	assumeRoleSessionName    = "eso-e2e-probe"
+)
+
+type awsAuthProfile string
+
+const (
+	awsAuthProfileStatic         awsAuthProfile = "static"
+	awsAuthProfileExternalID     awsAuthProfile = "external-id"
+	awsAuthProfileSessionTags    awsAuthProfile = "session-tags"
+	awsAuthProfileReferencedIRSA awsAuthProfile = "referenced-irsa"
+	awsAuthProfileMountedIRSA    awsAuthProfile = "mounted-irsa"
+)
+
+type awsAccessConfig struct {
+	KID         string
+	SAK         string
+	ST          string
+	Region      string
+	Role        string
+	SAName      string
+	SANamespace string
+}
+
+type secretsManagerBackend struct {
+	access     awsAccessConfig
+	client     *awssm.Client
+	clientErr  error
+	clientOnce sync.Once
+	framework  *framework.Framework
+}
+
+type stsAssumeRoleClient interface {
+	AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error)
+}
+
+type assumeRoleProbeKey struct {
+	access  awsAccessConfig
+	profile awsAuthProfile
+}
+
+type assumeRoleProbeResult struct {
+	err error
+}
+
+var assumeRoleProbeCache sync.Map
+
+func loadAWSAccessConfigFromEnv() awsAccessConfig {
+	return awsAccessConfig{
+		KID:         os.Getenv("AWS_ACCESS_KEY_ID"),
+		SAK:         os.Getenv("AWS_SECRET_ACCESS_KEY"),
+		ST:          os.Getenv("AWS_SESSION_TOKEN"),
+		Region:      os.Getenv("AWS_REGION"),
+		SAName:      os.Getenv("AWS_SA_NAME"),
+		SANamespace: os.Getenv("AWS_SA_NAMESPACE"),
+	}
+}
+
+func newBackendFromEnv(f *framework.Framework) *secretsManagerBackend {
+	return newSecretsManagerBackend(f, loadAWSAccessConfigFromEnv())
+}
+
+func newSecretsManagerBackend(f *framework.Framework, access awsAccessConfig) *secretsManagerBackend {
+	return &secretsManagerBackend{
+		access:    access,
+		framework: f,
+	}
+}
+
+func (c awsAccessConfig) missingStaticCredentials() []string {
+	var missing []string
+	if c.KID == "" {
+		missing = append(missing, "AWS_ACCESS_KEY_ID")
+	}
+	if c.SAK == "" {
+		missing = append(missing, "AWS_SECRET_ACCESS_KEY")
+	}
+	if c.Region == "" {
+		missing = append(missing, "AWS_REGION")
+	}
+	return missing
+}
+
+func skipIfAWSStaticCredentialsMissing(access awsAccessConfig) {
+	if missing := access.missingStaticCredentials(); len(missing) > 0 {
+		Skip("missing AWS e2e credentials: " + strings.Join(missing, ", "))
+	}
+}
+
+func skipIfAWSManagedIRSAEnvMissing(access awsAccessConfig) {
+	var missing []string
+	if access.Region == "" {
+		missing = append(missing, "AWS_REGION")
+	}
+	if access.SAName == "" {
+		missing = append(missing, "AWS_SA_NAME")
+	}
+	if access.SANamespace == "" {
+		missing = append(missing, "AWS_SA_NAMESPACE")
+	}
+	if len(missing) > 0 {
+		Skip("missing AWS managed IRSA environment: " + strings.Join(missing, ", "))
+	}
+}
+
+func skipIfAWSAssumeRoleProbeDenied(access awsAccessConfig, profile awsAuthProfile) {
+	if profile != awsAuthProfileExternalID && profile != awsAuthProfileSessionTags {
+		return
+	}
+
+	cacheKey := assumeRoleProbeKey{
+		access:  access,
+		profile: profile,
+	}
+	if cached, ok := assumeRoleProbeCache.Load(cacheKey); ok {
+		handleAssumeRoleProbeResult(access, profile, cached.(assumeRoleProbeResult).err)
+		return
+	}
+
+	cfg, err := loadAWSConfig(access)
+	Expect(err).NotTo(HaveOccurred())
+
+	err = probeAssumeRoleAccess(context.Background(), sts.NewFromConfig(cfg), access, profile)
+	assumeRoleProbeCache.Store(cacheKey, assumeRoleProbeResult{err: err})
+	handleAssumeRoleProbeResult(access, profile, err)
+}
+
+func handleAssumeRoleProbeResult(access awsAccessConfig, profile awsAuthProfile, err error) {
+	if err == nil {
+		return
+	}
+	if isAssumeRoleAccessDenied(err) {
+		Skip(fmt.Sprintf("skipping AWS %s auth e2e: %s is not authorized to assume role %q with the current credentials", profile, assumeRoleAction(profile), roleARNForProfile(access, profile)))
+	}
+	Expect(err).NotTo(HaveOccurred())
+}
+
+func assumeRoleAction(profile awsAuthProfile) string {
+	if profile == awsAuthProfileSessionTags {
+		return "sts:TagSession"
+	}
+	return "sts:AssumeRole"
+}
+
+func staticAWSAuth(secretName string) esv1.AWSAuth {
+	return esv1.AWSAuth{
+		SecretRef: &esv1.AWSAuthSecretRef{
+			AccessKeyID: esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticAccessKeyIDKey,
+			},
+			SecretAccessKey: esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticSecretAccessKeyKey,
+			},
+			SessionToken: &esmetav1.SecretKeySelector{
+				Name: secretName,
+				Key:  awscommon.StaticSessionTokenKey,
+			},
+		},
+	}
+}
+
+func newStaticCredentialsSecret(namespace, name string, access awsAccessConfig) *corev1.Secret {
+	return &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		StringData: awscommon.StaticCredentialsSecretData(access.KID, access.SAK, access.ST),
+	}
+}
+
+func createStaticCredentialsSecret(f *framework.Framework, namespace, name string, access awsAccessConfig) {
+	Expect(f.CRClient.Create(GinkgoT().Context(), newStaticCredentialsSecret(namespace, name, access))).To(Succeed())
+}
+
+func roleARNForProfile(access awsAccessConfig, profile awsAuthProfile) string {
+	if access.Role != "" {
+		return access.Role
+	}
+	switch profile {
+	case awsAuthProfileExternalID:
+		return awscommon.IAMRoleExternalID
+	case awsAuthProfileSessionTags:
+		return awscommon.IAMRoleSessionTags
+	default:
+		return ""
+	}
+}
+
+func sessionTagsForProfile(profile awsAuthProfile) []ststypes.Tag {
+	if profile != awsAuthProfileSessionTags {
+		return nil
+	}
+
+	return []ststypes.Tag{{
+		Key:   aws.String("namespace"),
+		Value: aws.String("e2e-test"),
+	}}
+}
+
+func probeAssumeRoleAccess(ctx context.Context, client stsAssumeRoleClient, access awsAccessConfig, profile awsAuthProfile) error {
+	if profile != awsAuthProfileExternalID && profile != awsAuthProfileSessionTags {
+		return nil
+	}
+
+	input := &sts.AssumeRoleInput{
+		RoleArn:         aws.String(roleARNForProfile(access, profile)),
+		RoleSessionName: aws.String(assumeRoleSessionName),
+		Tags:            sessionTagsForProfile(profile),
+	}
+	if profile == awsAuthProfileExternalID {
+		input.ExternalId = aws.String(awscommon.IAMTrustedExternalID)
+	}
+
+	_, err := client.AssumeRole(ctx, input)
+	return err
+}
+
+func isAssumeRoleAccessDenied(err error) bool {
+	if err == nil {
+		return false
+	}
+
+	msg := strings.ToLower(err.Error())
+	if !strings.Contains(msg, "accessdenied") {
+		return false
+	}
+	return strings.Contains(msg, "sts:assumerole") || strings.Contains(msg, "sts:tagsession")
+}
+
+func newSecretsManagerV2Config(namespace, name string, access awsAccessConfig, profile awsAuthProfile) *awsv2alpha1.SecretsManager {
+	cfg := &awsv2alpha1.SecretsManager{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: awsv2alpha1.GroupVersion.String(),
+			Kind:       awsv2alpha1.SecretsManagerKind,
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: awsv2alpha1.SecretsManagerSpec{
+			Region: access.Region,
+		},
+	}
+
+	switch profile {
+	case awsAuthProfileStatic:
+		cfg.Spec.Auth = staticAWSAuth(awscommon.CredentialsSecretName(name))
+	case awsAuthProfileExternalID:
+		cfg.Spec.Auth = staticAWSAuth(awscommon.CredentialsSecretName(name))
+		cfg.Spec.Role = access.Role
+		if cfg.Spec.Role == "" {
+			cfg.Spec.Role = awscommon.IAMRoleExternalID
+		}
+		cfg.Spec.ExternalID = awscommon.IAMTrustedExternalID
+	case awsAuthProfileSessionTags:
+		cfg.Spec.Auth = staticAWSAuth(awscommon.CredentialsSecretName(name))
+		cfg.Spec.Role = access.Role
+		if cfg.Spec.Role == "" {
+			cfg.Spec.Role = awscommon.IAMRoleSessionTags
+		}
+		cfg.Spec.SessionTags = []*esv1.Tag{{
+			Key:   "namespace",
+			Value: "e2e-test",
+		}}
+	case awsAuthProfileReferencedIRSA:
+		cfg.Spec.Auth = esv1.AWSAuth{
+			JWTAuth: &esv1.AWSJWTAuth{
+				ServiceAccountRef: &esmetav1.ServiceAccountSelector{
+					Name:      access.SAName,
+					Namespace: &access.SANamespace,
+				},
+			},
+		}
+	case awsAuthProfileMountedIRSA:
+		cfg.Spec.Auth = esv1.AWSAuth{}
+	default:
+		cfg.Spec.Auth = staticAWSAuth(awscommon.CredentialsSecretName(name))
+	}
+
+	return cfg
+}
+
+func createSecretsManagerV2Config(f *framework.Framework, namespace, name string, access awsAccessConfig, profile awsAuthProfile) *awsv2alpha1.SecretsManager {
+	if profile == awsAuthProfileStatic || profile == awsAuthProfileExternalID || profile == awsAuthProfileSessionTags {
+		createStaticCredentialsSecret(f, namespace, awscommon.CredentialsSecretName(name), access)
+	}
+
+	cfg := newSecretsManagerV2Config(namespace, name, access, profile)
+	Expect(f.CRClient.Create(GinkgoT().Context(), cfg)).To(Succeed())
+	return cfg
+}
+
+func createSecretsManagerV2ProviderConnection(f *framework.Framework, namespace, name, providerName, providerNamespace string) {
+	frameworkv2.CreateProviderConnection(
+		f,
+		namespace,
+		name,
+		frameworkv2.ProviderAddress("aws"),
+		awsProviderAPIVersion,
+		awsv2alpha1.SecretsManagerKind,
+		providerName,
+		providerNamespace,
+	)
+}
+
+func loadAWSConfig(access awsAccessConfig) (aws.Config, error) {
+	loadOptions := []func(*config.LoadOptions) error{
+		config.WithRegion(access.Region),
+	}
+	if access.KID != "" || access.SAK != "" || access.ST != "" {
+		loadOptions = append(loadOptions, config.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(access.KID, access.SAK, access.ST),
+		))
+	}
+
+	return config.LoadDefaultConfig(context.Background(), loadOptions...)
+}
+
+func (b *secretsManagerBackend) ensureClient() {
+	b.clientOnce.Do(func() {
+		cfg, err := loadAWSConfig(b.access)
+		if err != nil {
+			b.clientErr = err
+			return
+		}
+		b.client = awssm.NewFromConfig(cfg)
+	})
+
+	Expect(b.clientErr).ToNot(HaveOccurred())
+	Expect(b.client).NotTo(BeNil())
+}
+
+func (b *secretsManagerBackend) CreateSecret(key string, val framework.SecretEntry) {
+	b.ensureClient()
+
+	smTags := make([]secretsmanagertypes.Tag, 0, len(val.Tags))
+	for tagKey, tagValue := range val.Tags {
+		smTags = append(smTags, secretsmanagertypes.Tag{
+			Key:   aws.String(tagKey),
+			Value: aws.String(tagValue),
+		})
+	}
+
+	attempts := 20
+	for {
+		log.Logf("creating secret %s / attempts left: %d", key, attempts)
+		_, err := b.client.CreateSecret(GinkgoT().Context(), &awssm.CreateSecretInput{
+			Name:         aws.String(key),
+			SecretString: aws.String(val.Value),
+			Tags:         smTags,
+		})
+		if err == nil {
+			return
+		}
+		attempts--
+		if attempts < 0 {
+			Fail("unable to create secret: " + err.Error())
+		}
+		<-time.After(5 * time.Second)
+	}
+}
+
+func (b *secretsManagerBackend) DeleteSecret(key string) {
+	b.ensureClient()
+
+	log.Logf("deleting secret %s", key)
+	_, err := b.client.DeleteSecret(GinkgoT().Context(), &awssm.DeleteSecretInput{
+		SecretId:                   aws.String(key),
+		ForceDeleteWithoutRecovery: aws.Bool(true),
+	})
+	var notFound *secretsmanagertypes.ResourceNotFoundException
+	if errors.As(err, &notFound) {
+		return
+	}
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (b *secretsManagerBackend) WaitForSecretValue(name, expectedValue string) {
+	b.ensureClient()
+
+	Eventually(func(g Gomega) {
+		out, err := b.client.GetSecretValue(GinkgoT().Context(), &awssm.GetSecretValueInput{
+			SecretId: aws.String(name),
+		})
+		g.Expect(err).NotTo(HaveOccurred())
+		g.Expect(secretValueString(out)).To(Equal(expectedValue))
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(Succeed())
+}
+
+func (b *secretsManagerBackend) ExpectSecretAbsent(name string) {
+	b.ensureClient()
+
+	Eventually(func() bool {
+		_, err := b.client.GetSecretValue(GinkgoT().Context(), &awssm.GetSecretValueInput{
+			SecretId: aws.String(name),
+		})
+		return secretReadErrorIndicatesAbsence(err)
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(BeTrue(), fmt.Sprintf("expected AWS secret %q to be absent", name))
+}
+
+func secretValueString(out *awssm.GetSecretValueOutput) string {
+	if out == nil {
+		return ""
+	}
+	if out.SecretString != nil {
+		return aws.ToString(out.SecretString)
+	}
+	if len(out.SecretBinary) > 0 {
+		return string(out.SecretBinary)
+	}
+	return ""
+}
+
+func secretReadErrorIndicatesAbsence(err error) bool {
+	if err == nil {
+		return false
+	}
+
+	var notFound *secretsmanagertypes.ResourceNotFoundException
+	if errors.As(err, &notFound) {
+		return true
+	}
+
+	msg := strings.ToLower(err.Error())
+	return strings.Contains(msg, "marked for deletion") || strings.Contains(msg, "scheduled for deletion")
+}

+ 210 - 0
e2e/suites/provider/cases/aws/secretsmanager/provider_support_test.go

@@ -0,0 +1,210 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awssm "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
+	"github.com/aws/aws-sdk-go-v2/service/sts"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+type fakeSTSAssumeRoleClient struct {
+	input *sts.AssumeRoleInput
+	err   error
+}
+
+func (f *fakeSTSAssumeRoleClient) AssumeRole(_ context.Context, input *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) {
+	f.input = input
+	if f.err != nil {
+		return nil, f.err
+	}
+	return &sts.AssumeRoleOutput{}, nil
+}
+
+func TestProviderAddressInNamespace(t *testing.T) {
+	t.Parallel()
+
+	got := frameworkv2.ProviderAddressInNamespace("aws", "aws-irsa-system")
+	if got != "provider-aws.aws-irsa-system.svc:8080" {
+		t.Fatalf("unexpected address: %s", got)
+	}
+}
+
+func TestStaticAWSAuthUsesSessionTokenSelector(t *testing.T) {
+	t.Parallel()
+
+	auth := staticAWSAuth("aws-creds")
+	if auth.SecretRef == nil || auth.SecretRef.SessionToken == nil {
+		t.Fatal("expected session token selector to be preserved")
+	}
+	if auth.SecretRef.SessionToken.Name != "aws-creds" || auth.SecretRef.SessionToken.Key != "st" {
+		t.Fatalf("unexpected session token selector: %+v", auth.SecretRef.SessionToken)
+	}
+}
+
+func TestSecretsManagerConfigForExternalID(t *testing.T) {
+	t.Parallel()
+
+	cfg := newSecretsManagerV2Config("ns", "sm-extid", awsAccessConfig{
+		Region: "eu-west-1",
+		Role:   awscommon.IAMRoleExternalID,
+	}, awsAuthProfileExternalID)
+	if cfg.Spec.ExternalID != awscommon.IAMTrustedExternalID {
+		t.Fatalf("expected external ID %q, got %q", awscommon.IAMTrustedExternalID, cfg.Spec.ExternalID)
+	}
+}
+
+func TestSecretsManagerConfigForSessionTags(t *testing.T) {
+	t.Parallel()
+
+	cfg := newSecretsManagerV2Config("ns", "sm-tags", awsAccessConfig{
+		Region: "eu-west-1",
+		Role:   awscommon.IAMRoleSessionTags,
+	}, awsAuthProfileSessionTags)
+	if len(cfg.Spec.SessionTags) != 1 {
+		t.Fatalf("expected one session tag, got %d", len(cfg.Spec.SessionTags))
+	}
+	if cfg.Spec.SessionTags[0].Key != "namespace" || cfg.Spec.SessionTags[0].Value != "e2e-test" {
+		t.Fatalf("unexpected session tags: %+v", cfg.Spec.SessionTags)
+	}
+}
+
+func TestProviderConfigNamespaceForManifestScope(t *testing.T) {
+	t.Parallel()
+
+	if got := awscommon.ProviderConfigNamespace(esv1.AuthenticationScopeManifestNamespace, "provider-ns", "workload-ns"); got != "workload-ns" {
+		t.Fatalf("expected workload namespace, got %q", got)
+	}
+}
+
+func TestProviderConfigNamespaceForProviderScope(t *testing.T) {
+	t.Parallel()
+
+	if got := awscommon.ProviderConfigNamespace(esv1.AuthenticationScopeProviderNamespace, "provider-ns", "workload-ns"); got != "provider-ns" {
+		t.Fatalf("expected provider namespace, got %q", got)
+	}
+}
+
+func TestProviderReferenceNamespaceForManifestScope(t *testing.T) {
+	t.Parallel()
+
+	if got := awscommon.ProviderReferenceNamespace(esv1.AuthenticationScopeManifestNamespace, "provider-ns"); got != "" {
+		t.Fatalf("expected empty provider reference namespace, got %q", got)
+	}
+}
+
+func TestProviderReferenceNamespaceForProviderScope(t *testing.T) {
+	t.Parallel()
+
+	if got := awscommon.ProviderReferenceNamespace(esv1.AuthenticationScopeProviderNamespace, "provider-ns"); got != "provider-ns" {
+		t.Fatalf("expected provider namespace, got %q", got)
+	}
+}
+
+func TestSecretValueStringUsesSecretString(t *testing.T) {
+	t.Parallel()
+
+	got := secretValueString(&awssm.GetSecretValueOutput{
+		SecretString: aws.String(`{"value":"from-string"}`),
+		SecretBinary: []byte(`{"value":"from-binary"}`),
+	})
+	if got != `{"value":"from-string"}` {
+		t.Fatalf("expected SecretString payload, got %q", got)
+	}
+}
+
+func TestSecretValueStringFallsBackToSecretBinary(t *testing.T) {
+	t.Parallel()
+
+	got := secretValueString(&awssm.GetSecretValueOutput{
+		SecretBinary: []byte(`{"value":"from-binary"}`),
+	})
+	if got != `{"value":"from-binary"}` {
+		t.Fatalf("expected SecretBinary payload, got %q", got)
+	}
+}
+
+func TestSecretReadErrorIndicatesAbsenceRecognizesMarkedForDeletion(t *testing.T) {
+	t.Parallel()
+
+	err := errors.New("InvalidRequestException: You can't perform this operation on the secret because it was marked for deletion.")
+	if !secretReadErrorIndicatesAbsence(err) {
+		t.Fatal("expected marked-for-deletion error to be treated as absence")
+	}
+}
+
+func TestProbeAssumeRoleAccessBuildsExternalIDRequest(t *testing.T) {
+	t.Parallel()
+
+	client := &fakeSTSAssumeRoleClient{}
+	access := awsAccessConfig{
+		Role: awscommon.IAMRoleExternalID,
+	}
+	if err := probeAssumeRoleAccess(context.Background(), client, access, awsAuthProfileExternalID); err != nil {
+		t.Fatalf("probeAssumeRoleAccess() error = %v", err)
+	}
+	if client.input == nil {
+		t.Fatal("expected AssumeRole input to be recorded")
+	}
+	if got := aws.ToString(client.input.RoleArn); got != awscommon.IAMRoleExternalID {
+		t.Fatalf("expected role ARN %q, got %q", awscommon.IAMRoleExternalID, got)
+	}
+	if got := aws.ToString(client.input.ExternalId); got != awscommon.IAMTrustedExternalID {
+		t.Fatalf("expected external ID %q, got %q", awscommon.IAMTrustedExternalID, got)
+	}
+}
+
+func TestProbeAssumeRoleAccessBuildsSessionTagsRequest(t *testing.T) {
+	t.Parallel()
+
+	client := &fakeSTSAssumeRoleClient{}
+	access := awsAccessConfig{
+		Role: awscommon.IAMRoleSessionTags,
+	}
+	if err := probeAssumeRoleAccess(context.Background(), client, access, awsAuthProfileSessionTags); err != nil {
+		t.Fatalf("probeAssumeRoleAccess() error = %v", err)
+	}
+	if client.input == nil {
+		t.Fatal("expected AssumeRole input to be recorded")
+	}
+	if got := aws.ToString(client.input.RoleArn); got != awscommon.IAMRoleSessionTags {
+		t.Fatalf("expected role ARN %q, got %q", awscommon.IAMRoleSessionTags, got)
+	}
+	if len(client.input.Tags) != 1 {
+		t.Fatalf("expected one session tag, got %d", len(client.input.Tags))
+	}
+	tag := client.input.Tags[0]
+	if aws.ToString(tag.Key) != "namespace" || aws.ToString(tag.Value) != "e2e-test" {
+		t.Fatalf("unexpected session tag: %+v", tag)
+	}
+}
+
+func TestIsAssumeRoleAccessDeniedRecognizesSTSAccessDeniedErrors(t *testing.T) {
+	t.Parallel()
+
+	err := errors.New("api error AccessDenied: User is not authorized to perform: sts:TagSession")
+	if !isAssumeRoleAccessDenied(err) {
+		t.Fatal("expected sts access denied error to be recognized")
+	}
+}

+ 181 - 0
e2e/suites/provider/cases/aws/secretsmanager/provider_v2.go

@@ -0,0 +1,181 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"fmt"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+)
+
+var _ = Describe("[aws] v2 namespaced provider", Label("aws", "secretsmanager", "v2", "namespaced-provider"), func() {
+	f := framework.New("eso-aws-sm-v2")
+	prov := NewProviderV2(f)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("namespaced provider",
+		framework.TableFuncWithExternalSecret(f, prov),
+		framework.Compose(withStaticAuth, f, func(f *framework.Framework) (string, func(*framework.TestCase)) {
+			return common.NamespacedProviderSync(f, common.NamespacedProviderSyncConfig{
+				Description:        "[aws] should sync an ExternalSecret through a namespaced Provider using static credentials",
+				ExternalSecretName: "aws-v2-static-es",
+				TargetSecretName:   "aws-v2-static-target",
+				RemoteKey:          f.MakeRemoteRefKey("aws-v2-static-remote"),
+				RemoteSecretValue:  `{"value":"aws-v2-static-value"}`,
+				RemoteProperty:     "value",
+				SecretKey:          "value",
+				ExpectedValue:      "aws-v2-static-value",
+			})
+		}, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, func(f *framework.Framework) (string, func(*framework.TestCase)) {
+			return common.NamespacedProviderRefresh(f, common.NamespacedProviderRefreshConfig{
+				Description:         "[aws] should refresh synced secrets after the remote AWS secret changes",
+				ExternalSecretName:  "aws-v2-refresh-es",
+				TargetSecretName:    "aws-v2-refresh-target",
+				RemoteKey:           f.MakeRemoteRefKey("aws-v2-refresh-remote"),
+				InitialSecretValue:  `{"value":"aws-v2-initial"}`,
+				UpdatedSecretValue:  `{"value":"aws-v2-updated"}`,
+				RemoteProperty:      "value",
+				SecretKey:           "value",
+				InitialExpectedData: "aws-v2-initial",
+				UpdatedExpectedData: "aws-v2-updated",
+				RefreshInterval:     10 * time.Second,
+				WaitTimeout:         30 * time.Second,
+			})
+		}, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, func(f *framework.Framework) (string, func(*framework.TestCase)) {
+			return common.NamespacedProviderFind(f, common.NamespacedProviderFindConfig{
+				Description:        "[aws] should sync ExternalSecret dataFrom.find through a namespaced Provider",
+				ExternalSecretName: "aws-v2-find-es",
+				TargetSecretName:   "aws-v2-find-target",
+				MatchRegExp:        "^aws-v2-find-(one|two)$",
+				MatchingSecrets: map[string]string{
+					"aws-v2-find-one": "aws-v2-one",
+					"aws-v2-find-two": "aws-v2-two",
+				},
+				IgnoredSecrets: map[string]string{
+					"aws-v2-ignore": "aws-v2-ignore",
+				},
+			})
+		}, useV2StaticAuth(prov)),
+		framework.Compose(withStaticAuth, f, common.StatusNotUpdatedAfterSuccessfulSync, useV2StaticAuth(prov)),
+		framework.Compose(withExtID, f, SimpleSyncWithNamespaceTags(nil), useV2ExternalIDAuth(prov)),
+		framework.Compose(withSessionTags, f, SimpleSyncWithNamespaceTags(nil), useV2SessionTagsAuth(prov)),
+	)
+})
+
+type ProviderV2 struct {
+	access    awsAccessConfig
+	backend   *secretsManagerBackend
+	framework *framework.Framework
+}
+
+func NewProviderV2(f *framework.Framework) *ProviderV2 {
+	access := loadAWSAccessConfigFromEnv()
+	f.MakeRemoteRefKey = func(base string) string {
+		if f.Namespace == nil {
+			return base
+		}
+		suffix := f.Namespace.Name
+		if len(suffix) > 8 {
+			suffix = suffix[len(suffix)-8:]
+		}
+		if suffix == "" {
+			return base
+		}
+		return fmt.Sprintf("%s-%s", base, suffix)
+	}
+	prov := &ProviderV2{
+		access:    access,
+		backend:   newSecretsManagerBackend(f, access),
+		framework: f,
+	}
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			return
+		}
+		skipIfAWSStaticCredentialsMissing(access)
+	})
+
+	return prov
+}
+
+func (p *ProviderV2) CreateSecret(key string, val framework.SecretEntry) {
+	p.backend.CreateSecret(key, val)
+}
+
+func (p *ProviderV2) DeleteSecret(key string) {
+	p.backend.DeleteSecret(key)
+}
+
+func useV2StaticAuth(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileStatic)
+	}
+}
+
+func useV2ExternalIDAuth(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileExternalID)
+	}
+}
+
+func useV2SessionTagsAuth(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileSessionTags)
+	}
+}
+
+func (p *ProviderV2) prepareNamespacedProvider(profile awsAuthProfile) func(*framework.TestCase, framework.SecretStoreProvider) {
+	return p.prepareNamespacedProviderAtAddress(profile, frameworkv2.ProviderAddress("aws"))
+}
+
+func (p *ProviderV2) prepareNamespacedProviderAtAddress(profile awsAuthProfile, address string) func(*framework.TestCase, framework.SecretStoreProvider) {
+	return func(_ *framework.TestCase, _ framework.SecretStoreProvider) {
+		skipIfAWSAssumeRoleProbeDenied(p.access, profile)
+
+		configName := p.providerConfigName(profile)
+		createSecretsManagerV2Config(p.framework, p.framework.Namespace.Name, configName, p.access, profile)
+		frameworkv2.CreateProviderConnection(
+			p.framework,
+			p.framework.Namespace.Name,
+			p.framework.Namespace.Name,
+			address,
+			awsProviderAPIVersion,
+			awsv2alpha1.SecretsManagerKind,
+			configName,
+			p.framework.Namespace.Name,
+		)
+		frameworkv2.WaitForProviderConnectionReady(p.framework, p.framework.Namespace.Name, p.framework.Namespace.Name, defaultV2WaitTimeout)
+	}
+}
+
+func (p *ProviderV2) providerConfigName(profile awsAuthProfile) string {
+	return fmt.Sprintf("%s-%s", p.framework.Namespace.Name, profile)
+}

+ 161 - 0
e2e/suites/provider/cases/aws/secretsmanager/push_v2.go

@@ -0,0 +1,161 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+	"fmt"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+var _ = Describe("[aws] v2 push secret", Label("aws", "secretsmanager", "v2", "push-secret"), func() {
+	f := framework.New("eso-aws-sm-v2-push")
+	prov := NewProviderV2(f)
+	harness := newAWSClusterProviderPushHarness(f, prov)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	DescribeTable("push secret",
+		framework.TableFuncWithPushSecret(f, prov, nil),
+		Entry(awsPushSecretImplicitProviderKind(f, prov)),
+		Entry(awsPushSecretRejectsNamespacedRemoteNamespaceOverride(f, prov)),
+		Entry(common.ClusterProviderPushManifestNamespace(f, harness)),
+		Entry(common.ClusterProviderPushProviderNamespace(f, harness)),
+		Entry(common.ClusterProviderPushDeniedByConditions(f, harness)),
+	)
+})
+
+func newAWSClusterProviderPushHarness(f *framework.Framework, prov *ProviderV2) common.ClusterProviderPushHarness {
+	return common.ClusterProviderPushHarness{
+		Prepare: func(_ *framework.TestCase, cfg common.ClusterProviderConfig) *common.ClusterProviderPushRuntime {
+			s := newAWSClusterProviderScenario(f, cfg.Name, cfg.AuthScope, prov.access, prov.backend)
+			clusterProviderName := s.createClusterProvider(cfg.Conditions)
+			frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+			return &common.ClusterProviderPushRuntime{
+				ClusterProviderName:    clusterProviderName,
+				DefaultRemoteNamespace: "",
+				WaitForRemoteSecretValue: func(_, name, key, expectedValue string) {
+					s.waitForRemoteSecretValue(name, key, expectedValue)
+				},
+				ExpectNoRemoteSecret: func(_, name string) {
+					s.backend.ExpectSecretAbsent(name)
+				},
+			}
+		},
+	}
+}
+
+func (s *awsClusterProviderScenario) waitForRemoteSecretValue(name, key, expectedValue string) {
+	if key == "" {
+		s.backend.WaitForSecretValue(name, expectedValue)
+		return
+	}
+	s.backend.WaitForSecretValue(name, fmt.Sprintf(`{"%s":"%s"}`, key, expectedValue))
+}
+
+func awsPushSecretImplicitProviderKind(f *framework.Framework, prov *ProviderV2) (string, func(*framework.TestCase)) {
+	return "[aws] should support namespaced Provider refs when push kind is omitted", func(tc *framework.TestCase) {
+		remoteKey := f.MakeRemoteRefKey("aws-v2-push-implicit")
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileStatic)
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "aws-v2-push-implicit-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("value1"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "aws-v2-push-implicit"
+		tc.PushSecret.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyDelete
+		tc.PushSecret.Spec.SecretStoreRefs[0].Kind = ""
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "value",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: remoteKey,
+					Property:  "value",
+				},
+			},
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			awscommon.WaitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+			prov.backend.WaitForSecretValue(remoteKey, `{"value":"value1"}`)
+
+			Expect(tc.Framework.CRClient.Delete(context.Background(), ps)).To(Succeed())
+			prov.backend.ExpectSecretAbsent(remoteKey)
+		}
+	}
+}
+
+func awsPushSecretRejectsNamespacedRemoteNamespaceOverride(f *framework.Framework, prov *ProviderV2) (string, func(*framework.TestCase)) {
+	return "[aws] should reject remote namespace overrides when pushing through a namespaced Provider", func(tc *framework.TestCase) {
+		remoteKey := f.MakeRemoteRefKey("aws-v2-push-override")
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileStatic)
+		tc.PushSecretSource = &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "aws-v2-push-override-source",
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"value": []byte("should-not-push"),
+			},
+		}
+		tc.PushSecret.ObjectMeta.Name = "aws-v2-push-override"
+		tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+			Secret: &esv1alpha1.PushSecretSecret{
+				Name: tc.PushSecretSource.Name,
+			},
+		}
+		tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{{
+			Match: esv1alpha1.PushSecretMatch{
+				SecretKey: "value",
+				RemoteRef: esv1alpha1.PushSecretRemoteRef{
+					RemoteKey: remoteKey,
+					Property:  "value",
+				},
+			},
+			Metadata: awscommon.PushSecretMetadataWithRemoteNamespace("ignored-aws-namespace"),
+		}}
+		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+			awscommon.WaitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+			prov.backend.ExpectSecretAbsent(remoteKey)
+			awscommon.ExpectPushSecretEventMessage(tc.Framework, ps.Namespace, ps.Name, `unknown field "remoteNamespace"`)
+		}
+	}
+}

+ 88 - 0
e2e/suites/provider/cases/aws/secretsmanager/secretsmanager_v2_managed.go

@@ -0,0 +1,88 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/addon"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+)
+
+var _ = Describe("[awsmanaged] v2 IRSA via referenced service account", Label("aws", "secretsmanager", "managed", "v2"), Ordered, func() {
+	f := framework.New("eso-aws-managed-v2-ref")
+	prov := NewProviderV2(f)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+		skipIfAWSManagedIRSAEnvMissing(prov.access)
+	})
+
+	DescribeTable("sync secretsmanager secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		framework.Compose(awscommon.WithReferencedIRSA, f, common.SimpleDataSync, useV2ReferencedIRSA(prov)),
+		framework.Compose(awscommon.WithReferencedIRSA, f, common.FindByName, useV2ReferencedIRSA(prov)),
+	)
+})
+
+var _ = Describe("[awsmanaged] v2 with mounted IRSA", Label("aws", "secretsmanager", "managed", "v2"), Ordered, func() {
+	f := framework.New("eso-aws-managed-v2-mounted")
+	prov := NewProviderV2(f)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+		skipIfAWSManagedIRSAEnvMissing(prov.access)
+
+		f.Install(addon.NewESO(
+			addon.WithControllerClass(f.BaseName+"-mounted"),
+			addon.WithReleaseName(f.Namespace.Name),
+			addon.WithNamespace(prov.access.SANamespace),
+			addon.WithoutWebhook(),
+			addon.WithoutCertController(),
+			addon.WithV2AWSProvider(),
+			addon.WithV2ProviderServiceAccount("aws", prov.access.SAName),
+		))
+	})
+
+	DescribeTable("sync secretsmanager secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		framework.Compose(awscommon.WithMountedIRSA, f, common.SimpleDataSync, useV2MountedIRSA(prov)),
+		framework.Compose(awscommon.WithMountedIRSA, f, common.FindByName, useV2MountedIRSA(prov)),
+	)
+})
+
+func useV2ReferencedIRSA(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProvider(awsAuthProfileReferencedIRSA)
+	}
+}
+
+func useV2MountedIRSA(prov *ProviderV2) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		tc.Prepare = prov.prepareNamespacedProviderAtAddress(
+			awsAuthProfileMountedIRSA,
+			frameworkv2.ProviderAddressInNamespace("aws", prov.access.SANamespace),
+		)
+	}
+}

+ 131 - 0
e2e/suites/provider/cases/aws/v2_support.go

@@ -0,0 +1,131 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package common
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	. "github.com/onsi/gomega"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+const (
+	StaticAccessKeyIDKey     = "kid"
+	StaticSecretAccessKeyKey = "sak"
+	StaticSessionTokenKey    = "st"
+)
+
+type V2ClusterProviderScenario struct {
+	AuthScope            esv1.AuthenticationScope
+	ConfigName           string
+	ConfigNamespace      string
+	NamePrefix           string
+	ProviderNamespace    string
+	ProviderRefNamespace string
+	WorkloadNamespace    string
+}
+
+func CredentialsSecretName(name string) string {
+	return name + "-credentials"
+}
+
+func StaticCredentialsSecretData(kid, sak, st string) map[string]string {
+	return map[string]string{
+		StaticAccessKeyIDKey:     kid,
+		StaticSecretAccessKeyKey: sak,
+		StaticSessionTokenKey:    st,
+	}
+}
+
+func ProviderConfigNamespace(authScope esv1.AuthenticationScope, providerNamespace, workloadNamespace string) string {
+	if authScope == esv1.AuthenticationScopeProviderNamespace {
+		return providerNamespace
+	}
+	return workloadNamespace
+}
+
+func ProviderReferenceNamespace(authScope esv1.AuthenticationScope, providerNamespace string) string {
+	if authScope == esv1.AuthenticationScopeProviderNamespace {
+		return providerNamespace
+	}
+	return ""
+}
+
+func NewV2ClusterProviderScenario(workloadNamespace, prefix string, authScope esv1.AuthenticationScope, createProviderNamespace func(prefix string) string) V2ClusterProviderScenario {
+	providerNamespace := workloadNamespace
+	if authScope == esv1.AuthenticationScopeProviderNamespace && createProviderNamespace != nil {
+		providerNamespace = createProviderNamespace(prefix + "-provider")
+	}
+
+	return V2ClusterProviderScenario{
+		AuthScope:            authScope,
+		ConfigName:           fmt.Sprintf("%s-config", prefix),
+		ConfigNamespace:      ProviderConfigNamespace(authScope, providerNamespace, workloadNamespace),
+		NamePrefix:           fmt.Sprintf("%s-%s", workloadNamespace, prefix),
+		ProviderNamespace:    providerNamespace,
+		ProviderRefNamespace: ProviderReferenceNamespace(authScope, providerNamespace),
+		WorkloadNamespace:    workloadNamespace,
+	}
+}
+
+func (s V2ClusterProviderScenario) ClusterProviderName() string {
+	return fmt.Sprintf("%s-cluster-provider", s.NamePrefix)
+}
+
+func WaitForPushSecretStatus(f *framework.Framework, namespace, name string, status corev1.ConditionStatus) {
+	Eventually(func(g Gomega) {
+		var ps esv1alpha1.PushSecret
+		g.Expect(f.CRClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, &ps)).To(Succeed())
+		g.Expect(ps.Status.Conditions).NotTo(BeEmpty())
+		for _, condition := range ps.Status.Conditions {
+			if condition.Type == esv1alpha1.PushSecretReady && condition.Status == status {
+				return
+			}
+		}
+		g.Expect(false).To(BeTrue())
+	}, time.Minute, 5*time.Second).Should(Succeed())
+}
+
+func ExpectPushSecretEventMessage(f *framework.Framework, namespace, objectName, expectedMessage string) {
+	Eventually(func() string {
+		events, err := f.KubeClientSet.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{
+			FieldSelector: "involvedObject.name=" + objectName + ",involvedObject.kind=PushSecret",
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		messages := make([]string, 0, len(events.Items))
+		for _, event := range events.Items {
+			if event.Message != "" {
+				messages = append(messages, event.Message)
+			}
+		}
+		return fmt.Sprintf("%v", messages)
+	}, time.Minute, 5*time.Second).Should(ContainSubstring(expectedMessage))
+}
+
+func PushSecretMetadataWithRemoteNamespace(namespace string) *apiextensionsv1.JSON {
+	return &apiextensionsv1.JSON{Raw: []byte(fmt.Sprintf(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1","kind":"PushSecretMetadata","spec":{"remoteNamespace":"%s"}}`, namespace))}
+}

+ 46 - 42
e2e/suites/provider/cases/common/fake_provider.go

@@ -24,52 +24,56 @@ import (
 )
 
 func FakeProviderSync(f *framework.Framework) (string, func(*framework.TestCase)) {
-	remoteKey := fmt.Sprintf("fake-sync-%s", f.Namespace.Name)
-	return NamespacedProviderSync(f, NamespacedProviderSyncConfig{
-		Description:        "[fake] should sync a namespaced secret",
-		ExternalSecretName: "fake-sync-es",
-		TargetSecretName:   "fake-sync-target",
-		RemoteKey:          remoteKey,
-		RemoteSecretValue:  `{"value":"fake-sync-value"}`,
-		RemoteProperty:     "value",
-		SecretKey:          "value",
-		ExpectedValue:      "fake-sync-value",
-	})
+	return "[fake] should sync a namespaced secret", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderSync(f, NamespacedProviderSyncConfig{
+			Description:        "[fake] should sync a namespaced secret",
+			ExternalSecretName: "fake-sync-es",
+			TargetSecretName:   "fake-sync-target",
+			RemoteKey:          fmt.Sprintf("fake-sync-%s", f.Namespace.Name),
+			RemoteSecretValue:  `{"value":"fake-sync-value"}`,
+			RemoteProperty:     "value",
+			SecretKey:          "value",
+			ExpectedValue:      "fake-sync-value",
+		})
+		prepare(tc)
+	}
 }
 
 func FakeProviderRefresh(f *framework.Framework) (string, func(*framework.TestCase)) {
-	remoteKey := fmt.Sprintf("fake-refresh-%s", f.Namespace.Name)
-	return NamespacedProviderRefresh(f, NamespacedProviderRefreshConfig{
-		Description:         "[fake] should refresh after the provider data changes",
-		ExternalSecretName:  "fake-refresh-es",
-		TargetSecretName:    "fake-refresh-target",
-		RemoteKey:           remoteKey,
-		InitialSecretValue:  `{"value":"fake-initial-value"}`,
-		UpdatedSecretValue:  `{"value":"fake-updated-value"}`,
-		RemoteProperty:      "value",
-		SecretKey:           "value",
-		InitialExpectedData: "fake-initial-value",
-		UpdatedExpectedData: "fake-updated-value",
-		RefreshInterval:     10 * time.Second,
-		WaitTimeout:         30 * time.Second,
-	})
+	return "[fake] should refresh after the provider data changes", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderRefresh(f, NamespacedProviderRefreshConfig{
+			Description:         "[fake] should refresh after the provider data changes",
+			ExternalSecretName:  "fake-refresh-es",
+			TargetSecretName:    "fake-refresh-target",
+			RemoteKey:           fmt.Sprintf("fake-refresh-%s", f.Namespace.Name),
+			InitialSecretValue:  `{"value":"fake-initial-value"}`,
+			UpdatedSecretValue:  `{"value":"fake-updated-value"}`,
+			RemoteProperty:      "value",
+			SecretKey:           "value",
+			InitialExpectedData: "fake-initial-value",
+			UpdatedExpectedData: "fake-updated-value",
+			RefreshInterval:     10 * time.Second,
+			WaitTimeout:         30 * time.Second,
+		})
+		prepare(tc)
+	}
 }
 
 func FakeProviderFind(f *framework.Framework) (string, func(*framework.TestCase)) {
-	remoteKeyOne := fmt.Sprintf("fake-find-%s-one", f.Namespace.Name)
-	remoteKeyTwo := fmt.Sprintf("fake-find-%s-two", f.Namespace.Name)
-	remoteKeyThree := fmt.Sprintf("fake-find-ignore-%s", f.Namespace.Name)
-	return NamespacedProviderFind(f, NamespacedProviderFindConfig{
-		Description:        "[fake] should sync dataFrom.find matches",
-		ExternalSecretName: "fake-find-es",
-		TargetSecretName:   "fake-find-target",
-		MatchRegExp:        fmt.Sprintf("fake-find-%s-(one|two)", f.Namespace.Name),
-		MatchingSecrets: map[string]string{
-			remoteKeyOne: `{"value":"fake-find-one"}`,
-			remoteKeyTwo: `{"value":"fake-find-two"}`,
-		},
-		IgnoredSecrets: map[string]string{
-			remoteKeyThree: `{"value":"fake-ignore"}`,
-		},
-	})
+	return "[fake] should sync dataFrom.find matches", func(tc *framework.TestCase) {
+		_, prepare := NamespacedProviderFind(f, NamespacedProviderFindConfig{
+			Description:        "[fake] should sync dataFrom.find matches",
+			ExternalSecretName: "fake-find-es",
+			TargetSecretName:   "fake-find-target",
+			MatchRegExp:        fmt.Sprintf("fake-find-%s-(one|two)", f.Namespace.Name),
+			MatchingSecrets: map[string]string{
+				fmt.Sprintf("fake-find-%s-one", f.Namespace.Name): `{"value":"fake-find-one"}`,
+				fmt.Sprintf("fake-find-%s-two", f.Namespace.Name): `{"value":"fake-find-two"}`,
+			},
+			IgnoredSecrets: map[string]string{
+				fmt.Sprintf("fake-find-ignore-%s", f.Namespace.Name): `{"value":"fake-ignore"}`,
+			},
+		})
+		prepare(tc)
+	}
 }

+ 45 - 0
e2e/suites/provider/cases/common/fake_provider_test.go

@@ -0,0 +1,45 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package common
+
+import (
+	"testing"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+)
+
+func TestFakeProviderHelpersDoNotRequireNamespaceAtConstruction(t *testing.T) {
+	t.Parallel()
+
+	f := &framework.Framework{}
+
+	assertDoesNotPanic(t, func() { FakeProviderSync(f) })
+	assertDoesNotPanic(t, func() { FakeProviderRefresh(f) })
+	assertDoesNotPanic(t, func() { FakeProviderFind(f) })
+}
+
+func assertDoesNotPanic(t *testing.T, fn func()) {
+	t.Helper()
+
+	defer func() {
+		if r := recover(); r != nil {
+			t.Fatalf("unexpected panic: %v", r)
+		}
+	}()
+
+	fn()
+}

+ 29 - 0
e2e/suites/provider/cases/common/provider_runtime_test.go

@@ -19,6 +19,12 @@ package common
 import (
 	"strings"
 	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 )
 
 func TestClusterProviderExternalSecretRuntimeSupportsAuthLifecycle(t *testing.T) {
@@ -124,3 +130,26 @@ func TestApplyClusterProviderPushSecretPanicsWithClearMessageWhenRuntimeNil(t *t
 
 	applyClusterProviderPushSecret(nil, nil, "remote-secret")
 }
+
+func TestApplyClusterProviderPushSecretUsesSafeObjectNameIndependentOfRemoteKey(t *testing.T) {
+	tc := &framework.TestCase{
+		PushSecret: &esv1alpha1.PushSecret{},
+		PushSecretSource: &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "push-provider-source",
+			},
+		},
+	}
+	runtime := &ClusterProviderPushRuntime{
+		ClusterProviderName: "push-provider-cluster-provider",
+	}
+
+	applyClusterProviderPushSecret(tc, runtime, "/e2e/test-ns/push-provider-remote")
+
+	if got, want := tc.PushSecret.ObjectMeta.Name, "push-provider-source-push-secret"; got != want {
+		t.Fatalf("expected PushSecret name %q, got %q", want, got)
+	}
+	if got, want := tc.PushSecret.Spec.Data[0].Match.RemoteRef.RemoteKey, "/e2e/test-ns/push-provider-remote"; got != want {
+		t.Fatalf("expected remote key %q, got %q", want, got)
+	}
+}

+ 29 - 25
e2e/suites/provider/cases/common/push_secret.go

@@ -232,11 +232,12 @@ func ClusterProviderPushAllowsRemoteNamespaceOverride(f *framework.Framework, ha
 
 		var runtime *ClusterProviderPushRuntime
 		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey("push-remote-override-remote")
 			runtime = harness.Prepare(tc, ClusterProviderConfig{
 				Name:      "push-remote-override",
 				AuthScope: esv1.AuthenticationScopeManifestNamespace,
 			})
-			applyClusterProviderPushSecret(tc, runtime, "push-remote-override-remote")
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
 			if !runtime.SupportsRemoteNamespaceOverrides() {
 				Skip(fmt.Sprintf("provider %q does not support remote namespace override hooks", runtime.ClusterProviderName))
 			}
@@ -244,9 +245,9 @@ func ClusterProviderPushAllowsRemoteNamespaceOverride(f *framework.Framework, ha
 			tc.PushSecret.Spec.Data[0].Metadata = pushSecretMetadataWithRemoteNamespace(overrideNamespace)
 			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
 				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
-				runtime.WaitForRemoteSecretValue(overrideNamespace, "push-remote-override-remote", "value", "override-push-value")
+				runtime.WaitForRemoteSecretValue(overrideNamespace, remoteSecretName, "value", "override-push-value")
 				if runtime.SupportsRemoteAbsenceAssertions() {
-					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, "push-remote-override-remote")
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
 				}
 			}
 		}
@@ -267,6 +268,7 @@ func ClusterProviderPushDeniedByConditions(f *framework.Framework, harness Clust
 
 		var runtime *ClusterProviderPushRuntime
 		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey("push-deny-remote")
 			runtime = harness.Prepare(tc, ClusterProviderConfig{
 				Name:      "push-deny",
 				AuthScope: esv1.AuthenticationScopeManifestNamespace,
@@ -274,14 +276,14 @@ func ClusterProviderPushDeniedByConditions(f *framework.Framework, harness Clust
 					Namespaces: []string{"not-" + f.Namespace.Name},
 				}},
 			})
-			applyClusterProviderPushSecret(tc, runtime, "push-deny-remote")
-		}
-		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
-			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
-			if runtime.SupportsRemoteAbsenceAssertions() {
-				runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, "push-deny-remote")
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+				if runtime.SupportsRemoteAbsenceAssertions() {
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
+				}
+				expectEventMessage(tc.Framework, ps.Namespace, ps.Name, "PushSecret", fmt.Sprintf("using ClusterProvider %q is not allowed from namespace %q: denied by spec.conditions", runtime.ClusterProviderName, f.Namespace.Name))
 			}
-			expectEventMessage(tc.Framework, ps.Namespace, ps.Name, "PushSecret", fmt.Sprintf("using ClusterProvider %q is not allowed from namespace %q: denied by spec.conditions", runtime.ClusterProviderName, f.Namespace.Name))
 		}
 	}
 }
@@ -300,15 +302,16 @@ func clusterProviderPushSyncCase(f *framework.Framework, harness ClusterProvider
 
 		var runtime *ClusterProviderPushRuntime
 		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey(fmt.Sprintf("%s-remote", name))
 			runtime = harness.Prepare(tc, ClusterProviderConfig{
 				Name:      name,
 				AuthScope: authScope,
 			})
-			applyClusterProviderPushSecret(tc, runtime, fmt.Sprintf("%s-remote", name))
-		}
-		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
-			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
-			runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, fmt.Sprintf("%s-remote", name), "value", expectedValue)
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, remoteSecretName, "value", expectedValue)
+			}
 		}
 	}
 }
@@ -327,25 +330,26 @@ func clusterProviderPushRecoveryCase(f *framework.Framework, harness ClusterProv
 
 		var runtime *ClusterProviderPushRuntime
 		tc.Prepare = func(tc *framework.TestCase, _ framework.SecretStoreProvider) {
+			remoteSecretName := f.MakeRemoteRefKey(fmt.Sprintf("%s-remote", name))
 			runtime = harness.Prepare(tc, ClusterProviderConfig{
 				Name:      name,
 				AuthScope: authScope,
 			})
-			applyClusterProviderPushSecret(tc, runtime, fmt.Sprintf("%s-remote", name))
+			applyClusterProviderPushSecret(tc, runtime, remoteSecretName)
 			if !runtime.SupportsAuthLifecycle() {
 				Skip(fmt.Sprintf("provider %q does not support auth lifecycle recovery hooks", runtime.ClusterProviderName))
 			}
 			tc.PushSecret.Spec.RefreshInterval = &metav1.Duration{Duration: time.Hour}
 			runtime.BreakAuth()
-		}
-		tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
-			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
-			if runtime.SupportsRemoteAbsenceAssertions() {
-				runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, fmt.Sprintf("%s-remote", name))
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionFalse)
+				if runtime.SupportsRemoteAbsenceAssertions() {
+					runtime.ExpectNoRemoteSecret(runtime.DefaultRemoteNamespace, remoteSecretName)
+				}
+				runtime.RepairAuth()
+				waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
+				runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, remoteSecretName, "value", expectedValue)
 			}
-			runtime.RepairAuth()
-			waitForPushSecretStatus(tc.Framework, ps.Namespace, ps.Name, corev1.ConditionTrue)
-			runtime.WaitForRemoteSecretValue(runtime.DefaultRemoteNamespace, fmt.Sprintf("%s-remote", name), "value", expectedValue)
 		}
 	}
 }
@@ -355,7 +359,7 @@ func applyClusterProviderPushSecret(tc *framework.TestCase, runtime *ClusterProv
 		panic("cluster provider push harness returned nil runtime")
 	}
 
-	tc.PushSecret.ObjectMeta.Name = fmt.Sprintf("%s-push-secret", remoteSecretName)
+	tc.PushSecret.ObjectMeta.Name = fmt.Sprintf("%s-push-secret", tc.PushSecretSource.Name)
 	tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{{
 		Name:       runtime.ClusterProviderName,
 		Kind:       esv1.ClusterProviderKindStr,

+ 82 - 0
e2e/suites/provider/cases/fake/provider_v2_test.go

@@ -0,0 +1,82 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package fake
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func TestUpsertFakeProviderDataReplacesMatchingEntry(t *testing.T) {
+	input := []esv1.FakeProviderData{
+		{Key: "other", Value: "untouched"},
+		{Key: "remote", Value: "old", Version: "v1"},
+	}
+
+	got := upsertFakeProviderData(input, esv1.FakeProviderData{
+		Key:     "remote",
+		Value:   "new",
+		Version: "v1",
+	})
+
+	want := []esv1.FakeProviderData{
+		{Key: "other", Value: "untouched"},
+		{Key: "remote", Value: "new", Version: "v1"},
+	}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("upsertFakeProviderData mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestRemoveFakeProviderDataRemovesOnlyExactMatch(t *testing.T) {
+	input := []esv1.FakeProviderData{
+		{Key: "remote", Value: "keep-version", Version: "v2"},
+		{Key: "remote", Value: "drop-version", Version: "v1"},
+		{Key: "other", Value: "keep"},
+	}
+
+	got := removeFakeProviderData(input, "remote", "v1")
+
+	want := []esv1.FakeProviderData{
+		{Key: "remote", Value: "keep-version", Version: "v2"},
+		{Key: "other", Value: "keep"},
+	}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("removeFakeProviderData mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestProviderReferenceNamespace(t *testing.T) {
+	if got := providerReferenceNamespace(esv1.AuthenticationScopeManifestNamespace, "provider-ns"); got != "" {
+		t.Fatalf("expected empty providerRef namespace for manifest scope, got %q", got)
+	}
+	if got := providerReferenceNamespace(esv1.AuthenticationScopeProviderNamespace, "provider-ns"); got != "provider-ns" {
+		t.Fatalf("expected providerRef namespace for provider scope, got %q", got)
+	}
+}
+
+func TestFakeConfigNamespaceForAuthScope(t *testing.T) {
+	if got := fakeConfigNamespaceForAuthScope(esv1.AuthenticationScopeManifestNamespace, "manifest-ns", "provider-ns"); got != "manifest-ns" {
+		t.Fatalf("expected manifest namespace for manifest scope, got %q", got)
+	}
+	if got := fakeConfigNamespaceForAuthScope(esv1.AuthenticationScopeProviderNamespace, "manifest-ns", "provider-ns"); got != "provider-ns" {
+		t.Fatalf("expected provider namespace for provider scope, got %q", got)
+	}
+}

+ 7 - 0
e2e/suites/provider/cases/fake/regressions.go

@@ -26,6 +26,13 @@ var _ = Describe("[fake] ", Label("fake"), func() {
 	f := framework.New("eso-fake")
 	prov := NewProvider(f)
 
+	DescribeTable("namespaced provider",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(common.FakeProviderSync(f)),
+		Entry(common.FakeProviderRefresh(f)),
+		Entry(common.FakeProviderFind(f)),
+	)
+
 	// we use the fake provider to test regressions
 	DescribeTable("controller regressions",
 		framework.TableFuncWithExternalSecret(f, prov),

+ 53 - 26
providers/v2/aws/config.go

@@ -32,36 +32,63 @@ import (
 // This function converts v2 ProviderReference to v1 SecretStoreSpec.
 func GetSpecMapper(kubeClient client.Client) func(*pb.ProviderReference, string) (*v1.SecretStoreSpec, error) {
 	return func(ref *pb.ProviderReference, sourceNamespace string) (*v1.SecretStoreSpec, error) {
-		if ref.Kind != awsv2alpha1.SecretsManagerKind {
-			return nil, fmt.Errorf("unsupported provider kind: %s", ref.Kind)
-		}
 		namespace := ref.Namespace
 		if namespace == "" {
 			namespace = sourceNamespace
 		}
-		var awsProvider awsv2alpha1.SecretsManager
-		err := kubeClient.Get(context.Background(), client.ObjectKey{
-			Namespace: namespace,
-			Name:      ref.Name,
-		}, &awsProvider)
-		if err != nil {
-			return nil, err
-		}
-		return &v1.SecretStoreSpec{
-			Provider: &v1.SecretStoreProvider{
-				AWS: &v1.AWSProvider{
-					Service:           v1.AWSServiceSecretsManager,
-					Auth:              awsProvider.Spec.Auth,
-					Role:              awsProvider.Spec.Role,
-					Region:            awsProvider.Spec.Region,
-					AdditionalRoles:   awsProvider.Spec.AdditionalRoles,
-					ExternalID:        awsProvider.Spec.ExternalID,
-					SecretsManager:    awsProvider.Spec.SecretsManager,
-					SessionTags:       awsProvider.Spec.SessionTags,
-					TransitiveTagKeys: awsProvider.Spec.TransitiveTagKeys,
-					Prefix:            awsProvider.Spec.Prefix,
+
+		switch ref.Kind {
+		case awsv2alpha1.SecretsManagerKind:
+			var awsProvider awsv2alpha1.SecretsManager
+			err := kubeClient.Get(context.Background(), client.ObjectKey{
+				Namespace: namespace,
+				Name:      ref.Name,
+			}, &awsProvider)
+			if err != nil {
+				return nil, err
+			}
+			return &v1.SecretStoreSpec{
+				Provider: &v1.SecretStoreProvider{
+					AWS: &v1.AWSProvider{
+						Service:           v1.AWSServiceSecretsManager,
+						Auth:              awsProvider.Spec.Auth,
+						Role:              awsProvider.Spec.Role,
+						Region:            awsProvider.Spec.Region,
+						AdditionalRoles:   awsProvider.Spec.AdditionalRoles,
+						ExternalID:        awsProvider.Spec.ExternalID,
+						SecretsManager:    awsProvider.Spec.SecretsManager,
+						SessionTags:       awsProvider.Spec.SessionTags,
+						TransitiveTagKeys: awsProvider.Spec.TransitiveTagKeys,
+						Prefix:            awsProvider.Spec.Prefix,
+					},
 				},
-			},
-		}, nil
+			}, nil
+		case awsv2alpha1.ParameterStoreKind:
+			var awsProvider awsv2alpha1.ParameterStore
+			err := kubeClient.Get(context.Background(), client.ObjectKey{
+				Namespace: namespace,
+				Name:      ref.Name,
+			}, &awsProvider)
+			if err != nil {
+				return nil, err
+			}
+			return &v1.SecretStoreSpec{
+				Provider: &v1.SecretStoreProvider{
+					AWS: &v1.AWSProvider{
+						Service:           v1.AWSServiceParameterStore,
+						Auth:              awsProvider.Spec.Auth,
+						Role:              awsProvider.Spec.Role,
+						Region:            awsProvider.Spec.Region,
+						AdditionalRoles:   awsProvider.Spec.AdditionalRoles,
+						ExternalID:        awsProvider.Spec.ExternalID,
+						SessionTags:       awsProvider.Spec.SessionTags,
+						TransitiveTagKeys: awsProvider.Spec.TransitiveTagKeys,
+						Prefix:            awsProvider.Spec.Prefix,
+					},
+				},
+			}, nil
+		default:
+			return nil, fmt.Errorf("unsupported provider kind: %s", ref.Kind)
+		}
 	}
 }

+ 120 - 0
providers/v2/aws/config_test.go

@@ -0,0 +1,120 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"testing"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+func TestGetSpecMapperMapsParameterStore(t *testing.T) {
+	t.Parallel()
+
+	scheme := runtime.NewScheme()
+	if err := clientgoscheme.AddToScheme(scheme); err != nil {
+		t.Fatalf("AddToScheme() error = %v", err)
+	}
+	if err := awsv2alpha1.AddToScheme(scheme); err != nil {
+		t.Fatalf("AddToScheme() error = %v", err)
+	}
+
+	kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&awsv2alpha1.ParameterStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "ps-config",
+			Namespace: "provider-ns",
+		},
+		Spec: awsv2alpha1.ParameterStoreSpec{
+			Region: "eu-central-1",
+			Role:   "arn:aws:iam::123456789012:role/eso-ssm",
+			Prefix: "/team-a/",
+			ExternalID: "ext-id",
+		},
+	}).Build()
+
+	spec, err := GetSpecMapper(kubeClient)(&pb.ProviderReference{
+		ApiVersion: awsv2alpha1.GroupVersion.String(),
+		Kind:       awsv2alpha1.ParameterStoreKind,
+		Name:       "ps-config",
+		Namespace:  "provider-ns",
+	}, "workload-ns")
+	if err != nil {
+		t.Fatalf("GetSpecMapper() error = %v", err)
+	}
+	if spec.Provider == nil || spec.Provider.AWS == nil {
+		t.Fatal("expected AWS provider spec to be returned")
+	}
+	if spec.Provider.AWS.Service != esv1.AWSServiceParameterStore {
+		t.Fatalf("expected service %q, got %q", esv1.AWSServiceParameterStore, spec.Provider.AWS.Service)
+	}
+	if spec.Provider.AWS.Region != "eu-central-1" {
+		t.Fatalf("expected region to be preserved, got %q", spec.Provider.AWS.Region)
+	}
+	if spec.Provider.AWS.Role != "arn:aws:iam::123456789012:role/eso-ssm" {
+		t.Fatalf("expected role to be preserved, got %q", spec.Provider.AWS.Role)
+	}
+	if spec.Provider.AWS.Prefix != "/team-a/" {
+		t.Fatalf("expected prefix to be preserved, got %q", spec.Provider.AWS.Prefix)
+	}
+	if spec.Provider.AWS.ExternalID != "ext-id" {
+		t.Fatalf("expected external ID to be preserved, got %q", spec.Provider.AWS.ExternalID)
+	}
+}
+
+func TestGetSpecMapperUsesSourceNamespaceForParameterStore(t *testing.T) {
+	t.Parallel()
+
+	scheme := runtime.NewScheme()
+	if err := clientgoscheme.AddToScheme(scheme); err != nil {
+		t.Fatalf("AddToScheme() error = %v", err)
+	}
+	if err := awsv2alpha1.AddToScheme(scheme); err != nil {
+		t.Fatalf("AddToScheme() error = %v", err)
+	}
+
+	kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&awsv2alpha1.ParameterStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "ps-source-ns",
+			Namespace: "workload-ns",
+		},
+		Spec: awsv2alpha1.ParameterStoreSpec{
+			Region: "eu-west-1",
+		},
+	}).Build()
+
+	spec, err := GetSpecMapper(kubeClient)(&pb.ProviderReference{
+		ApiVersion: awsv2alpha1.GroupVersion.String(),
+		Kind:       awsv2alpha1.ParameterStoreKind,
+		Name:       "ps-source-ns",
+	}, "workload-ns")
+	if err != nil {
+		t.Fatalf("GetSpecMapper() error = %v", err)
+	}
+	if spec.Provider == nil || spec.Provider.AWS == nil {
+		t.Fatal("expected AWS provider spec to be returned")
+	}
+	if spec.Provider.AWS.Service != esv1.AWSServiceParameterStore {
+		t.Fatalf("expected service %q, got %q", esv1.AWSServiceParameterStore, spec.Provider.AWS.Service)
+	}
+}

+ 7 - 4
providers/v2/aws/main.go

@@ -1,11 +1,9 @@
 /*
-Copyright © The ESO Authors
-
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
-    https://www.apache.org/licenses/LICENSE-2.0
+    http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
@@ -19,7 +17,6 @@ limitations under the License.
 package main
 
 import (
-	"context"
 	"flag"
 	"fmt"
 	"log"
@@ -76,12 +73,18 @@ func main() {
 	}
 	// Setup v1 provider(s)
 	v1Provider0 := store.NewProvider()
+	v1Provider1 := store.NewProvider()
 	providerMapping := adapterstore.ProviderMapping{
 		schema.GroupVersionKind{
 			Group:   "provider.external-secrets.io",
 			Version: "v2alpha1",
 			Kind:    "SecretsManager",
 		}: v1Provider0,
+		schema.GroupVersionKind{
+			Group:   "provider.external-secrets.io",
+			Version: "v2alpha1",
+			Kind:    "ParameterStore",
+		}: v1Provider1,
 	}
 
 	specMapper := GetSpecMapper(kubeClient)

+ 6 - 1
providers/v2/aws/provider.yaml

@@ -10,6 +10,12 @@ stores:
       kind: "SecretsManager"
     v1Provider: "github.com/external-secrets/external-secrets/providers/v2/aws/store"
     v1ProviderFunc: "NewProvider"
+  - gvk:
+      group: "provider.external-secrets.io"
+      version: "v2alpha1"
+      kind: "ParameterStore"
+    v1Provider: "github.com/external-secrets/external-secrets/providers/v2/aws/store"
+    v1ProviderFunc: "NewProvider"
 
 generators:
   - gvk:
@@ -26,4 +32,3 @@ generators:
     v1GeneratorFunc: "NewSTSGenerator"
 
 configPackage: "."
-

+ 1 - 4
providers/v2/fake/main.go

@@ -1,11 +1,9 @@
 /*
-Copyright © The ESO Authors
-
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
-    https://www.apache.org/licenses/LICENSE-2.0
+    http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
@@ -19,7 +17,6 @@ limitations under the License.
 package main
 
 import (
-	"context"
 	"flag"
 	"fmt"
 	"log"

+ 0 - 4
providers/v2/hack/templates/main.go.tmpl

@@ -19,7 +19,6 @@ limitations under the License.
 package main
 
 import (
-	"context"
 	"flag"
 	"fmt"
 	"log"
@@ -37,9 +36,6 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/config"
 
-	{{- if .HasStores}}
-	v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-	{{- end}}
 	{{- if .HasGenerators}}
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	{{- end}}

+ 1 - 4
providers/v2/kubernetes/main.go

@@ -1,11 +1,9 @@
 /*
-Copyright © The ESO Authors
-
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
-    https://www.apache.org/licenses/LICENSE-2.0
+    http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
@@ -19,7 +17,6 @@ limitations under the License.
 package main
 
 import (
-	"context"
 	"flag"
 	"fmt"
 	"log"