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

feat(AWS): add `replicationLocations` to AWS SecretsManager provider (#6451)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Charles Moscofian <charlesmoscofian@hotmail.com>
Charles Moscofian 10 часов назад
Родитель
Сommit
f99bb91ac6

+ 40 - 3
docs/provider/aws-secrets-manager.md

@@ -65,7 +65,9 @@ If you're planning to use `PushSecret`, ensure you also have the following permi
     "secretsmanager:DeleteSecret",
     "secretsmanager:GetResourcePolicy",
     "secretsmanager:PutResourcePolicy",
-    "secretsmanager:DeleteResourcePolicy"
+    "secretsmanager:DeleteResourcePolicy",
+    "secretsmanager:ReplicateSecretToRegions",
+    "secretsmanager:RemoveRegionsFromReplication"
   ],
   "Resource": [
     "arn:aws:secretsmanager:us-west-2:111122223333:secret:dev-*"
@@ -74,6 +76,7 @@ If you're planning to use `PushSecret`, ensure you also have the following permi
 ```
 
 **Note:** The resource policy permissions (`GetResourcePolicy`, `PutResourcePolicy`, `DeleteResourcePolicy`) are only required if you're using the `resourcePolicy` metadata option to manage resource-based policies on secrets.
+**Note:** The replication permissions (`ReplicateSecretToRegions`, `RemoveRegionsFromReplication`) are only required if you're using the `replicationLocations` metadata option to manage secret replication across multiple regions.
 
 Here's a more restrictive version of the IAM policy:
 
@@ -89,7 +92,9 @@ Here's a more restrictive version of the IAM policy:
         "secretsmanager:TagResource",
         "secretsmanager:GetResourcePolicy",
         "secretsmanager:PutResourcePolicy",
-        "secretsmanager:DeleteResourcePolicy"
+        "secretsmanager:DeleteResourcePolicy",
+        "secretsmanager:ReplicateSecretToRegions",
+        "secretsmanager:RemoveRegionsFromReplication"
       ],
       "Resource": [
         "arn:aws:secretsmanager:us-west-2:111122223333:secret:dev-*"
@@ -132,6 +137,7 @@ Optionally, it is possible to configure additional options for the parameter. Th
 - description
 - tags
 - resourcePolicy
+- replicationLocations
 
 To control this behavior set the following provider metadata:
 
@@ -143,8 +149,9 @@ To control this behavior set the following provider metadata:
 - `kmsKeyID` takes a KMS Key `$ID` or `$ARN` (in case a key source is created in another account) as a string, where `alias/aws/secretsmanager` is the _default_.
 - `description` Description of the secret.
 - `tags` Key-value map of user-defined tags that are attached to the secret.
+- `replicationLocations` takes a list of valid AWS region names where the secret should be replicated.
 
-**Note:** ESO treats the PushSecret as the **source of truth** for tags. Tags specified in `metadata.tags` will be added or updated, and tags NOT specified will be removed from AWS. This synchronization happens on every reconciliation, even when the secret value hasn't changed.
+**Note:** ESO treats the PushSecret as the **source of truth** for tags, resource policy, and replication locations. When any of these resources are specified in `metadata`, they will be added or updated, and resources NOT specified but existing will be removed from AWS. This synchronization happens on every reconciliation, even when the secret value hasn't changed.
 
 - `resourcePolicy` Attach a resource-based policy to the secret for cross-account access or advanced access control.
   - `blockPublicPolicy` (optional) - Set to `true` to validate that the policy doesn't grant public access before applying. Defaults to AWS behavior.
@@ -153,6 +160,15 @@ To control this behavior set the following provider metadata:
     - `name` - Name of the ConfigMap or Secret.
     - `key` - Key within the ConfigMap/Secret data that contains the policy JSON.
 
+
+##### KMS Key
+
+The `kmsKeyID` field controls the KMS key used for encrypting/ decrypting the secret.
+
+- If `kmsKeyID` is provided, ESO always uses that value for the primary secret.
+- If `kmsKeyID` is not provided, ESO falls back to AWS’s default Secrets Manager key: `alias/aws/secretsmanager`.
+- ESO does not currently support specifying different `kmsKeyID` values per replica region. A single `kmsKeyID` value is applied uniformly across the primary secret and all configured replication regions.
+
 ##### Resource Policy Example
 
 To attach a resource policy to a secret for cross-account access:
@@ -218,6 +234,27 @@ data:
 
 **Note:** The resource policy is synchronized on every reconciliation, even when the secret value hasn't changed. If the `resourcePolicy` field is removed from metadata, the existing policy will be deleted from the secret.
 
+##### Location Replication
+
+When this field is set, _ESO_ manages replication as part of the PushSecret reconciliation loop and treats the list as the desired state:
+
+- Regions present in `replicationLocations` but not yet configured in AWS will be added.
+- Regions already configured in AWS but not listed in `replicationLocations` will be removed.
+- If `replicationLocations` is omitted entirely, ESO does not manage replication for that secret.
+- Invalid/unsupported region values or missing permissions will cause the AWS replication call to fail.
+
+**Note**: Replicas do not support per-region KMS key selection. If you configure replication, all replica regions will use the same `kmsKeyID` value defined in the main metadata block, or `alias/aws/secretsmanager` when no key is specified.
+
+**Note**: The KMS key **must be available** in the replication region, usually via KMS key replication.
+
+###### Location Replication Example
+
+You can specify a list of locations for your secrets to be replicated by setting the `replicationLocations` field.
+
+``` yaml
+{% include 'aws-sm-push-secret-with-replication.yaml' %}
+```
+
 ### JSON Secret Values
 
 SecretsManager supports *simple* key/value pairs that are stored as json. If you use the API you can store more complex JSON objects. You can access nested values or arrays using [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md):

+ 28 - 0
docs/snippets/aws-sm-push-secret-with-replication.yaml

@@ -0,0 +1,28 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-example
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: aws-secretsmanager
+      kind: SecretStore
+  selector:
+    secret:
+      name: pokedex-credentials
+  data:
+    - match:
+        secretKey: my-secret-key
+        remoteRef:
+          remoteKey: my-remote-secret
+          property: password
+      metadata:
+        kmsKeyID: bb123123-b2b0-4f60-ac3a-44a13f0e6b6c
+        replicationLocations:
+          - eu-north-1
+          - eu-west-2
+        secretPushFormat: string
+        description: "Cross-account accessible secret"
+        tags:
+          team: platform-engineering

+ 69 - 28
providers/v1/aws/secretsmanager/fake/fake.go

@@ -32,34 +32,45 @@ import (
 
 // Client implements the aws secretsmanager interface.
 type Client struct {
-	ExecutionCounter       int
-	valFn                  map[string]func(*awssm.GetSecretValueInput) (*awssm.GetSecretValueOutput, error)
-	CreateSecretFn         CreateSecretFn
-	GetSecretValueFn       GetSecretValueFn
-	PutSecretValueFn       PutSecretValueFn
-	DescribeSecretFn       DescribeSecretFn
-	DeleteSecretFn         DeleteSecretFn
-	ListSecretsFn          ListSecretsFn
-	BatchGetSecretValueFn  BatchGetSecretValueFn
-	TagResourceFn          TagResourceFn
-	UntagResourceFn        UntagResourceFn
-	PutResourcePolicyFn    PutResourcePolicyFn
-	GetResourcePolicyFn    GetResourcePolicyFn
-	DeleteResourcePolicyFn DeleteResourcePolicyFn
-}
-type CreateSecretFn func(context.Context, *awssm.CreateSecretInput, ...func(*awssm.Options)) (*awssm.CreateSecretOutput, error)
-type GetSecretValueFn func(context.Context, *awssm.GetSecretValueInput, ...func(*awssm.Options)) (*awssm.GetSecretValueOutput, error)
-type PutSecretValueFn func(context.Context, *awssm.PutSecretValueInput, ...func(*awssm.Options)) (*awssm.PutSecretValueOutput, error)
-type DescribeSecretFn func(context.Context, *awssm.DescribeSecretInput, ...func(*awssm.Options)) (*awssm.DescribeSecretOutput, error)
-type DeleteSecretFn func(context.Context, *awssm.DeleteSecretInput, ...func(*awssm.Options)) (*awssm.DeleteSecretOutput, error)
-type ListSecretsFn func(context.Context, *awssm.ListSecretsInput, ...func(*awssm.Options)) (*awssm.ListSecretsOutput, error)
-type BatchGetSecretValueFn func(context.Context, *awssm.BatchGetSecretValueInput, ...func(*awssm.Options)) (*awssm.BatchGetSecretValueOutput, error)
-
-type TagResourceFn func(context.Context, *awssm.TagResourceInput, ...func(*awssm.Options)) (*awssm.TagResourceOutput, error)
-type UntagResourceFn func(context.Context, *awssm.UntagResourceInput, ...func(*awssm.Options)) (*awssm.UntagResourceOutput, error)
-type PutResourcePolicyFn func(context.Context, *awssm.PutResourcePolicyInput, ...func(*awssm.Options)) (*awssm.PutResourcePolicyOutput, error)
-type GetResourcePolicyFn func(context.Context, *awssm.GetResourcePolicyInput, ...func(*awssm.Options)) (*awssm.GetResourcePolicyOutput, error)
-type DeleteResourcePolicyFn func(context.Context, *awssm.DeleteResourcePolicyInput, ...func(*awssm.Options)) (*awssm.DeleteResourcePolicyOutput, error)
+	ExecutionCounter               int
+	valFn                          map[string]func(*awssm.GetSecretValueInput) (*awssm.GetSecretValueOutput, error)
+	CreateSecretFn                 CreateSecretFn
+	GetSecretValueFn               GetSecretValueFn
+	PutSecretValueFn               PutSecretValueFn
+	DescribeSecretFn               DescribeSecretFn
+	DeleteSecretFn                 DeleteSecretFn
+	ListSecretsFn                  ListSecretsFn
+	BatchGetSecretValueFn          BatchGetSecretValueFn
+	TagResourceFn                  TagResourceFn
+	UntagResourceFn                UntagResourceFn
+	PutResourcePolicyFn            PutResourcePolicyFn
+	GetResourcePolicyFn            GetResourcePolicyFn
+	DeleteResourcePolicyFn         DeleteResourcePolicyFn
+	ReplicateSecretToRegionsFn     ReplicateSecretToRegionsFn
+	RemoveRegionsFromReplicationFn RemoveRegionsFromReplicationFn
+}
+type (
+	CreateSecretFn        func(context.Context, *awssm.CreateSecretInput, ...func(*awssm.Options)) (*awssm.CreateSecretOutput, error)
+	GetSecretValueFn      func(context.Context, *awssm.GetSecretValueInput, ...func(*awssm.Options)) (*awssm.GetSecretValueOutput, error)
+	PutSecretValueFn      func(context.Context, *awssm.PutSecretValueInput, ...func(*awssm.Options)) (*awssm.PutSecretValueOutput, error)
+	DescribeSecretFn      func(context.Context, *awssm.DescribeSecretInput, ...func(*awssm.Options)) (*awssm.DescribeSecretOutput, error)
+	DeleteSecretFn        func(context.Context, *awssm.DeleteSecretInput, ...func(*awssm.Options)) (*awssm.DeleteSecretOutput, error)
+	ListSecretsFn         func(context.Context, *awssm.ListSecretsInput, ...func(*awssm.Options)) (*awssm.ListSecretsOutput, error)
+	BatchGetSecretValueFn func(context.Context, *awssm.BatchGetSecretValueInput, ...func(*awssm.Options)) (*awssm.BatchGetSecretValueOutput, error)
+)
+
+type (
+	TagResourceFn          func(context.Context, *awssm.TagResourceInput, ...func(*awssm.Options)) (*awssm.TagResourceOutput, error)
+	UntagResourceFn        func(context.Context, *awssm.UntagResourceInput, ...func(*awssm.Options)) (*awssm.UntagResourceOutput, error)
+	PutResourcePolicyFn    func(context.Context, *awssm.PutResourcePolicyInput, ...func(*awssm.Options)) (*awssm.PutResourcePolicyOutput, error)
+	GetResourcePolicyFn    func(context.Context, *awssm.GetResourcePolicyInput, ...func(*awssm.Options)) (*awssm.GetResourcePolicyOutput, error)
+	DeleteResourcePolicyFn func(context.Context, *awssm.DeleteResourcePolicyInput, ...func(*awssm.Options)) (*awssm.DeleteResourcePolicyOutput, error)
+)
+
+type (
+	ReplicateSecretToRegionsFn     func(context.Context, *awssm.ReplicateSecretToRegionsInput, ...func(*awssm.Options)) (*awssm.ReplicateSecretToRegionsOutput, error)
+	RemoveRegionsFromReplicationFn func(context.Context, *awssm.RemoveRegionsFromReplicationInput, ...func(*awssm.Options)) (*awssm.RemoveRegionsFromReplicationOutput, error)
+)
 
 func (sm *Client) CreateSecret(ctx context.Context, input *awssm.CreateSecretInput, options ...func(*awssm.Options)) (*awssm.CreateSecretOutput, error) {
 	return sm.CreateSecretFn(ctx, input, options...)
@@ -270,3 +281,33 @@ func NewDeleteResourcePolicyFn(output *awssm.DeleteResourcePolicyOutput, err err
 		return output, err
 	}
 }
+
+func NewReplicateSecretToRegionsFn(output *awssm.ReplicateSecretToRegionsOutput, err error, aFunc ...func(input *awssm.ReplicateSecretToRegionsInput)) ReplicateSecretToRegionsFn {
+	return func(ctx context.Context, params *awssm.ReplicateSecretToRegionsInput, optFns ...func(*awssm.Options)) (*awssm.ReplicateSecretToRegionsOutput, error) {
+		for _, f := range aFunc {
+			f(params)
+		}
+		return output, err
+	}
+}
+
+func (sm *Client) ReplicateSecretToRegions(ctx context.Context, params *awssm.ReplicateSecretToRegionsInput, optFns ...func(*awssm.Options)) (*awssm.ReplicateSecretToRegionsOutput, error) {
+	return sm.ReplicateSecretToRegionsFn(ctx, params, optFns...)
+}
+
+func NewRemoveRegionsFromReplicationFn(output *awssm.RemoveRegionsFromReplicationOutput, err error, aFunc ...func(input *awssm.RemoveRegionsFromReplicationInput)) RemoveRegionsFromReplicationFn {
+	return func(ctx context.Context, params *awssm.RemoveRegionsFromReplicationInput, optFns ...func(*awssm.Options)) (*awssm.RemoveRegionsFromReplicationOutput, error) {
+		for _, f := range aFunc {
+			f(params)
+		}
+		return output, err
+	}
+}
+
+func (sm *Client) RemoveRegionsFromReplication(
+	ctx context.Context,
+	params *awssm.RemoveRegionsFromReplicationInput,
+	optFns ...func(*awssm.Options),
+) (*awssm.RemoveRegionsFromReplicationOutput, error) {
+	return sm.RemoveRegionsFromReplicationFn(ctx, params, optFns...)
+}

+ 129 - 6
providers/v1/aws/secretsmanager/secretsmanager.go

@@ -54,6 +54,10 @@ type PushSecretMetadataSpec struct {
 	SecretPushFormat string              `json:"secretPushFormat,omitempty"`
 	KMSKeyID         string              `json:"kmsKeyId,omitempty"`
 	ResourcePolicy   *ResourcePolicySpec `json:"resourcePolicy,omitempty"`
+	// ReplicationLocations defines one or more user-managed replication
+	// locations for the secret. This is useful for High Availability across
+	// regions.
+	ReplicationLocations []string `json:"replicationLocations,omitempty"`
 }
 
 // ResourcePolicySpec defines the resource policy configuration using PolicySourceRef for AWS Secrets Manager.
@@ -108,6 +112,8 @@ type SMInterface interface {
 	PutResourcePolicy(ctx context.Context, params *awssm.PutResourcePolicyInput, optFuncs ...func(*awssm.Options)) (*awssm.PutResourcePolicyOutput, error)
 	GetResourcePolicy(ctx context.Context, params *awssm.GetResourcePolicyInput, optFuncs ...func(*awssm.Options)) (*awssm.GetResourcePolicyOutput, error)
 	DeleteResourcePolicy(ctx context.Context, params *awssm.DeleteResourcePolicyInput, optFuncs ...func(*awssm.Options)) (*awssm.DeleteResourcePolicyOutput, error)
+	ReplicateSecretToRegions(ctx context.Context, params *awssm.ReplicateSecretToRegionsInput, optFuncs ...func(*awssm.Options)) (*awssm.ReplicateSecretToRegionsOutput, error)
+	RemoveRegionsFromReplication(ctx context.Context, params *awssm.RemoveRegionsFromReplicationInput, optFuncs ...func(*awssm.Options)) (*awssm.RemoveRegionsFromReplicationOutput, error)
 }
 
 const (
@@ -268,7 +274,7 @@ func (sm *SecretsManager) PushSecret(ctx context.Context, secret *corev1.Secret,
 		if err != nil {
 			return err
 		}
-		return sm.putSecretValueWithContext(ctx, secretName, nil, psd, finalValue, describeSecretOutput.Tags)
+		return sm.putSecretValueWithContext(ctx, secretName, nil, psd, finalValue, describeSecretOutput)
 	}
 
 	getSecretValueInput := awssm.GetSecretValueInput{SecretId: &secretName}
@@ -282,7 +288,7 @@ func (sm *SecretsManager) PushSecret(ctx context.Context, secret *corev1.Secret,
 	if err != nil {
 		return err
 	}
-	return sm.putSecretValueWithContext(ctx, secretName, getSecretValueOutput, psd, finalValue, describeSecretOutput.Tags)
+	return sm.putSecretValueWithContext(ctx, secretName, getSecretValueOutput, psd, finalValue, describeSecretOutput)
 }
 
 func (sm *SecretsManager) getNewSecretValue(value []byte, property string, existingSecret *awssm.GetSecretValueOutput) ([]byte, error) {
@@ -570,13 +576,15 @@ func (sm *SecretsManager) createSecretWithContext(ctx context.Context, secretNam
 		})
 	}
 
+	kmsKeyID := aws.String(mdata.Spec.KMSKeyID)
 	input := &awssm.CreateSecretInput{
 		Name:               &secretName,
 		SecretBinary:       value,
 		Tags:               tags,
 		Description:        new(mdata.Spec.Description),
 		ClientRequestToken: new(initialVersion),
-		KmsKeyId:           new(mdata.Spec.KMSKeyID),
+		KmsKeyId:           kmsKeyID,
+		AddReplicaRegions:  buildReplicationRegionType(mdata.Spec.ReplicationLocations, kmsKeyID),
 	}
 	if mdata.Spec.SecretPushFormat == SecretPushFormatString {
 		input.SecretBinary = nil
@@ -614,9 +622,16 @@ func (sm *SecretsManager) createSecretWithContext(ctx context.Context, secretNam
 	return nil
 }
 
-func (sm *SecretsManager) putSecretValueWithContext(ctx context.Context, secretArn string, awsSecret *awssm.GetSecretValueOutput, psd esv1.PushSecretData, value []byte, tags []types.Tag) error {
-	currentTags := make(map[string]string, len(tags))
-	for _, tag := range tags {
+func (sm *SecretsManager) putSecretValueWithContext(
+	ctx context.Context,
+	secretArn string,
+	awsSecret *awssm.GetSecretValueOutput,
+	psd esv1.PushSecretData,
+	value []byte,
+	describeSecret *awssm.DescribeSecretOutput,
+) error {
+	currentTags := make(map[string]string, len(describeSecret.Tags))
+	for _, tag := range describeSecret.Tags {
 		currentTags[*tag.Key] = *tag.Value
 	}
 	if err := sm.patchTags(ctx, psd.GetMetadata(), &secretArn, currentTags); err != nil {
@@ -627,6 +642,10 @@ func (sm *SecretsManager) putSecretValueWithContext(ctx context.Context, secretA
 		return err
 	}
 
+	if err := sm.manageRegionReplication(ctx, psd.GetMetadata(), &secretArn, describeSecret.KmsKeyId, describeSecret.ReplicationStatus); err != nil {
+		return err
+	}
+
 	if awsSecret != nil && (bytes.Equal(awsSecret.SecretBinary, value) || esutils.CompareStringAndByteSlices(awsSecret.SecretString, value)) {
 		return nil
 	}
@@ -987,3 +1006,107 @@ func computeTagsToUpdate(tags, metaTags map[string]string) ([]types.Tag, bool) {
 	}
 	return result, modified
 }
+
+// manageRegionReplication add or remove regions for secret replication based on
+// desired and live state.
+func (sm *SecretsManager) manageRegionReplication(ctx context.Context, metadata *apiextensionsv1.JSON, secretARN, kmsKeyID *string, existingReplicationStatusType []types.ReplicationStatusType) error {
+	meta, err := sm.constructMetadataWithDefaults(metadata)
+	if err != nil {
+		return err
+	}
+
+	// NOTE: skip replication completely unless explicitly set in the desired state
+	if meta.Spec.ReplicationLocations == nil {
+		return nil
+	}
+
+	existingReplicationRegions := buildExistingReplicationRegionsSlice(existingReplicationStatusType)
+	requiresRegionReplicationRemoval, regionsToBeRemovedFromReplication := sm.getReplicationRegionToBeRemoved(meta.Spec.ReplicationLocations, existingReplicationRegions)
+	if requiresRegionReplicationRemoval {
+		if err := sm.removeRegionsFromReplication(ctx, secretARN, regionsToBeRemovedFromReplication); err != nil {
+			return err
+		}
+	}
+
+	requiresNewRegionReplication, regionsToReplicate := sm.getReplicationRegionsToBeAdded(meta.Spec.ReplicationLocations, existingReplicationRegions)
+	if requiresNewRegionReplication {
+		if err := sm.replicateExistingSecretToRegions(ctx, secretARN, kmsKeyID, regionsToReplicate); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (sm *SecretsManager) replicateExistingSecretToRegions(ctx context.Context, secretID, kmsKeyID *string, regionsToReplicate []string) error {
+	replicateSecretToRegionsInput := &awssm.ReplicateSecretToRegionsInput{
+		AddReplicaRegions: buildReplicationRegionType(regionsToReplicate, kmsKeyID),
+		SecretId:          secretID,
+	}
+	_, err := sm.client.ReplicateSecretToRegions(ctx, replicateSecretToRegionsInput)
+	metrics.ObserveAPICall(constants.ProviderAWSSM, constants.CallAWSSMReplicateSecretToRegions, err)
+	if err != nil {
+		return fmt.Errorf("failed to replicate existing secret to regions: %w", err)
+	}
+	return nil
+}
+
+func (sm *SecretsManager) removeRegionsFromReplication(ctx context.Context, secretID *string, replicationRegionsToBeRemoved []string) error {
+	removeRegionsFromReplicationInput := &awssm.RemoveRegionsFromReplicationInput{
+		RemoveReplicaRegions: replicationRegionsToBeRemoved,
+		SecretId:             secretID,
+	}
+	_, err := sm.client.RemoveRegionsFromReplication(ctx, removeRegionsFromReplicationInput)
+	metrics.ObserveAPICall(constants.ProviderAWSSM, constants.CallAWSSMRemoveRegionsFromReplication, err)
+	if err != nil {
+		return fmt.Errorf("failed to remove regions from secret replication: %w", err)
+	}
+	return nil
+}
+
+func (sm *SecretsManager) getReplicationRegionToBeRemoved(desiredReplicationRegions, existingReplicationRegions []string) (bool, []string) {
+	regionsDifference := getDifferenceFromSlices(existingReplicationRegions, desiredReplicationRegions)
+	return len(regionsDifference) > 0, regionsDifference
+}
+
+func (sm *SecretsManager) getReplicationRegionsToBeAdded(desiredReplicationRegions, existingReplicationRegions []string) (bool, []string) {
+	regionsDifference := getDifferenceFromSlices(desiredReplicationRegions, existingReplicationRegions)
+	return len(regionsDifference) > 0, regionsDifference
+}
+
+func buildExistingReplicationRegionsSlice(existingReplicationRegions []types.ReplicationStatusType) []string {
+	replicationRegions := make([]string, 0, len(existingReplicationRegions))
+	for _, replicationStatusType := range existingReplicationRegions {
+		replicationRegions = append(replicationRegions, aws.ToString(replicationStatusType.Region))
+	}
+	return replicationRegions
+}
+
+func buildReplicationRegionType(regions []string, kmsKeyID *string) []types.ReplicaRegionType {
+	replicationRegionsType := make([]types.ReplicaRegionType, 0, len(regions))
+	for _, region := range regions {
+		replicationRegionType := types.ReplicaRegionType{
+			Region:   aws.String(region),
+			KmsKeyId: kmsKeyID,
+		}
+		replicationRegionsType = append(replicationRegionsType, replicationRegionType)
+	}
+	return replicationRegionsType
+}
+
+func getDifferenceFromSlices[T comparable](source, other []T) []T {
+	otherSet := make(map[T]struct{}, len(other))
+	result := make([]T, 0, len(source))
+
+	for _, key := range other {
+		otherSet[key] = struct{}{}
+	}
+
+	for _, key := range source {
+		if _, ok := otherSet[key]; !ok {
+			result = append(result, key)
+		}
+	}
+
+	return result
+}

+ 172 - 0
providers/v1/aws/secretsmanager/secretsmanager_test.go

@@ -1253,6 +1253,178 @@ func TestSetSecret(t *testing.T) {
 				err: nil,
 			},
 		},
+		"SetSecretWithRegionReplication": {
+			reason: "create a new secret with replication to extra regions",
+			args: args{
+				store: makeValidSecretStore().Spec.Provider.AWS,
+				client: fakesm.Client{
+					DescribeSecretFn: fakesm.NewDescribeSecretFn(blankDescribeSecretOutput, &getSecretCorrectErr),
+					CreateSecretFn:   fakesm.NewCreateSecretFn(secretOutput, nil),
+				},
+				pushSecretData: fake.PushSecretData{
+					SecretKey: secretKey,
+					RemoteKey: fakeKey,
+					Property:  "",
+					Metadata: &apiextensionsv1.JSON{
+						Raw: []byte(`{
+							"apiVersion": "kubernetes.external-secrets.io/v1alpha1",
+							"kind": "PushSecretMetadata",
+							"spec": {
+								"secretPushFormat": "string",
+								"replicationLocations": [
+									"eu-north-1",
+									"eu-central-1"
+								]
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"SetReplicationOnSecretWhileKeepingExistingReplication": {
+			reason: "sync an existing secret with existing replication region previously setup",
+			args: args{
+				store: makeValidSecretStore().Spec.Provider.AWS,
+				client: fakesm.Client{
+					GetSecretValueFn: fakesm.NewGetSecretValueFn(secretValueOutput, nil),
+					PutSecretValueFn: fakesm.NewPutSecretValueFn(putSecretOutput, nil),
+					DescribeSecretFn: fakesm.NewDescribeSecretFn(&awssm.DescribeSecretOutput{
+						ARN:  &arn,
+						Tags: externalSecretsTag,
+						VersionIdsToStages: map[string][]string{
+							defaultVersion: {"AWSCURRENT"},
+						},
+						KmsKeyId: aws.String("bb123123-b2b0-4f60-ac3a-44a13f0e6b6c"),
+						ReplicationStatus: []types.ReplicationStatusType{
+							// Existing replication region not part of the desired state (to be removed)
+							{KmsKeyId: aws.String("bb123123-b2b0-4f60-ac3a-44a13f0e6b6c"), Region: aws.String("eu-west-3"), Status: types.StatusTypeInSync},
+							// Existing replication region part of the desired state (kept).
+							{KmsKeyId: aws.String("bb123123-b2b0-4f60-ac3a-44a13f0e6b6c"), Region: aws.String("eu-north-1"), Status: types.StatusTypeInSync},
+							// Existing replication region not part of the desired state with failed status
+							{KmsKeyId: aws.String("bb123123-b2b0-4f60-ac3a-44a13f0e6b6c"), Region: aws.String("sa-east-1"), Status: types.StatusTypeFailed},
+							// Existing replication region not part of the desired state with in-progress status
+							{KmsKeyId: aws.String("bb123123-b2b0-4f60-ac3a-44a13f0e6b6c"), Region: aws.String("ap-southeast-2"), Status: types.StatusTypeInProgress},
+						},
+					}, nil),
+					DeleteResourcePolicyFn: fakesm.NewDeleteResourcePolicyFn(nil, &types.ResourceNotFoundException{}),
+					ReplicateSecretToRegionsFn: fakesm.NewReplicateSecretToRegionsFn(
+						&awssm.ReplicateSecretToRegionsOutput{},
+						nil,
+						func(got *awssm.ReplicateSecretToRegionsInput) {
+							assert.Len(t, got.AddReplicaRegions, 1)
+							assert.EqualValues(t, got.AddReplicaRegions[0].Region, aws.String("eu-central-1"))
+						},
+					),
+					RemoveRegionsFromReplicationFn: fakesm.NewRemoveRegionsFromReplicationFn(
+						&awssm.RemoveRegionsFromReplicationOutput{},
+						nil,
+						func(got *awssm.RemoveRegionsFromReplicationInput) {
+							assert.ElementsMatch(t, []string{"eu-west-3", "sa-east-1", "ap-southeast-2"}, got.RemoveReplicaRegions)
+						},
+					),
+				},
+				pushSecretData: fake.PushSecretData{
+					SecretKey: secretKey,
+					RemoteKey: fakeKey,
+					Property:  "",
+					Metadata: &apiextensionsv1.JSON{
+						Raw: []byte(`{
+							"apiVersion": "kubernetes.external-secrets.io/v1alpha1",
+							"kind": "PushSecretMetadata",
+							"spec": {
+								"secretPushFormat": "string",
+								"replicationLocations": [
+									"eu-north-1",
+									"eu-central-1"
+								]
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"SetReplicationOnSecretWithoutPreviousExistingReplications": {
+			reason: "sync an existing secret with no previous replication region previously setup",
+			args: args{
+				store: makeValidSecretStore().Spec.Provider.AWS,
+				client: fakesm.Client{
+					GetSecretValueFn:       fakesm.NewGetSecretValueFn(secretValueOutput, nil),
+					PutSecretValueFn:       fakesm.NewPutSecretValueFn(putSecretOutput, nil),
+					DescribeSecretFn:       fakesm.NewDescribeSecretFn(tagSecretOutput, nil),
+					DeleteResourcePolicyFn: fakesm.NewDeleteResourcePolicyFn(nil, &types.ResourceNotFoundException{}),
+					ReplicateSecretToRegionsFn: fakesm.NewReplicateSecretToRegionsFn(
+						&awssm.ReplicateSecretToRegionsOutput{},
+						nil,
+						func(got *awssm.ReplicateSecretToRegionsInput) {
+							assert.EqualValues(t, got.AddReplicaRegions, []types.ReplicaRegionType{{Region: aws.String("eu-north-1")}, {Region: aws.String("eu-central-1")}})
+							assert.Len(t, got.AddReplicaRegions, 2)
+							assert.EqualValues(t, got.AddReplicaRegions[0].Region, aws.String("eu-north-1"))
+							assert.EqualValues(t, got.AddReplicaRegions[1].Region, aws.String("eu-central-1"))
+						},
+					),
+				},
+				pushSecretData: fake.PushSecretData{
+					SecretKey: secretKey,
+					RemoteKey: fakeKey,
+					Property:  "",
+					Metadata: &apiextensionsv1.JSON{
+						Raw: []byte(`{
+							"apiVersion": "kubernetes.external-secrets.io/v1alpha1",
+							"kind": "PushSecretMetadata",
+							"spec": {
+								"secretPushFormat": "string",
+								"replicationLocations": [
+									"eu-north-1",
+									"eu-central-1"
+								]
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"SetReplicationForInvalidRegionFails": {
+			reason: "sync an existing secret with existing replication region previously setup",
+			args: args{
+				store: makeValidSecretStore().Spec.Provider.AWS,
+				client: fakesm.Client{
+					GetSecretValueFn:           fakesm.NewGetSecretValueFn(secretValueOutput, nil),
+					PutSecretValueFn:           fakesm.NewPutSecretValueFn(putSecretOutput, nil),
+					DescribeSecretFn:           fakesm.NewDescribeSecretFn(tagSecretOutput, nil),
+					DeleteResourcePolicyFn:     fakesm.NewDeleteResourcePolicyFn(nil, &types.ResourceNotFoundException{}),
+					ReplicateSecretToRegionsFn: fakesm.NewReplicateSecretToRegionsFn(nil, &types.InvalidRequestException{}),
+				},
+				pushSecretData: fake.PushSecretData{
+					SecretKey: secretKey,
+					RemoteKey: fakeKey,
+					Property:  "",
+					Metadata: &apiextensionsv1.JSON{
+						Raw: []byte(`{
+							"apiVersion": "kubernetes.external-secrets.io/v1alpha1",
+							"kind": "PushSecretMetadata",
+							"spec": {
+								"secretPushFormat": "string",
+								"replicationLocations": [
+									"xx-invalid-1"
+								]
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				err: errors.New("failed to replicate existing secret to regions"),
+			},
+		},
 	}
 
 	for name, tc := range tests {

+ 17 - 15
runtime/constants/constants.go

@@ -19,21 +19,23 @@ package constants
 
 // These constants are used for identifying providers and calls to them.
 const (
-	ProviderAWSSM                 = "AWS/SecretsManager"
-	CallAWSSMGetSecretValue       = "GetSecretValue"
-	CallAWSPSGetParametersByPath  = "GetParametersByPath"
-	CallAWSSMDescribeSecret       = "DescribeSecret"
-	CallAWSSMDeleteSecret         = "DeleteSecret"
-	CallAWSSMCreateSecret         = "CreateSecret"
-	CallAWSSMPutSecretValue       = "PutSecretValue"
-	CallAWSSMListSecrets          = "ListSecrets"
-	CallAWSSMBatchGetSecretValue  = "BatchGetSecretValue"
-	CallAWSSMUntagResource        = "UntagResource"
-	CallAWSSMTagResource          = "TagResource"
-	CallAWSSMPutResourcePolicy    = "PutResourcePolicy"
-	CallAWSSMGetResourcePolicy    = "GetResourcePolicy"
-	CallAWSSMDeleteResourcePolicy = "DeleteResourcePolicy"
-	ProviderAWSPS                 = "AWS/ParameterStore"
+	ProviderAWSSM                         = "AWS/SecretsManager"
+	CallAWSSMGetSecretValue               = "GetSecretValue"
+	CallAWSPSGetParametersByPath          = "GetParametersByPath"
+	CallAWSSMDescribeSecret               = "DescribeSecret"
+	CallAWSSMDeleteSecret                 = "DeleteSecret"
+	CallAWSSMCreateSecret                 = "CreateSecret"
+	CallAWSSMPutSecretValue               = "PutSecretValue"
+	CallAWSSMListSecrets                  = "ListSecrets"
+	CallAWSSMBatchGetSecretValue          = "BatchGetSecretValue"
+	CallAWSSMUntagResource                = "UntagResource"
+	CallAWSSMTagResource                  = "TagResource"
+	CallAWSSMPutResourcePolicy            = "PutResourcePolicy"
+	CallAWSSMGetResourcePolicy            = "GetResourcePolicy"
+	CallAWSSMDeleteResourcePolicy         = "DeleteResourcePolicy"
+	CallAWSSMReplicateSecretToRegions     = "ReplicateSecretToRegions"
+	CallAWSSMRemoveRegionsFromReplication = "RemoveRegionsFromReplication"
+	ProviderAWSPS                         = "AWS/ParameterStore"
 
 	CallAWSPSGetParameter        = "GetParameter"
 	CallAWSPSPutParameter        = "PutParameter"