Browse Source

feat(pushsecret): add dataTo support for bulk secret pushing (#5850)

Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Mohamed Rekiba 6 days ago
parent
commit
a89abc6147

+ 63 - 0
apis/externalsecrets/v1alpha1/pushsecret_types.go

@@ -30,6 +30,8 @@ const (
 	ReasonSynced = "Synced"
 	// ReasonErrored indicates that the push secret encountered an error during sync.
 	ReasonErrored = "Errored"
+	// ReasonSourceDeleted indicates that the source Secret was deleted and provider secrets were cleaned up.
+	ReasonSourceDeleted = "SourceDeleted"
 )
 
 // PushSecretStoreRef contains a reference on how to sync to a SecretStore.
@@ -107,8 +109,13 @@ type PushSecretSpec struct {
 	Selector PushSecretSelector `json:"selector"`
 
 	// Secret Data that should be pushed to providers
+	// +optional
 	Data []PushSecretData `json:"data,omitempty"`
 
+	// DataTo defines bulk push rules that expand source Secret keys into provider entries.
+	// +optional
+	DataTo []PushSecretDataTo `json:"dataTo,omitempty"`
+
 	// Template defines a blueprint for the created Secret resource.
 	// +optional
 	Template *esv1.ExternalSecretTemplate `json:"template,omitempty"`
@@ -205,6 +212,62 @@ func (d PushSecretData) GetProperty() string {
 	return d.Match.RemoteRef.Property
 }
 
+// PushSecretDataTo defines how to bulk-push secrets to providers without explicit per-key mappings.
+// +kubebuilder:validation:XValidation:rule="has(self.storeRef) && (has(self.storeRef.name) || has(self.storeRef.labelSelector))",message="storeRef must specify either name or labelSelector"
+// +kubebuilder:validation:XValidation:rule="!has(self.remoteKey) || !has(self.rewrite) || size(self.rewrite) == 0",message="remoteKey and rewrite are mutually exclusive: rewrite is only supported in per-key mode (without remoteKey)"
+type PushSecretDataTo struct {
+	// StoreRef specifies which SecretStore to push to. Required.
+	StoreRef *PushSecretStoreRef `json:"storeRef,omitempty"`
+
+	// RemoteKey is the name of the single provider secret that will receive ALL
+	// matched keys bundled as a JSON object (e.g. {"DB_HOST":"...","DB_USER":"..."}).
+	// When set, per-key expansion is skipped and a single push is performed.
+	// The provider's store prefix (if any) is still prepended to this value.
+	// When not set, each matched key is pushed as its own individual provider secret.
+	// +optional
+	RemoteKey string `json:"remoteKey,omitempty"`
+
+	// Match pattern for selecting keys from the source Secret.
+	// If not specified, all keys are selected.
+	// +optional
+	Match *PushSecretDataToMatch `json:"match,omitempty"`
+
+	// Rewrite operations to transform keys before pushing to the provider.
+	// Operations are applied sequentially.
+	// +optional
+	Rewrite []PushSecretRewrite `json:"rewrite,omitempty"`
+
+	// Metadata is metadata attached to the secret.
+	// The structure of metadata is provider specific, please look it up in the provider documentation.
+	// +optional
+	Metadata *apiextensionsv1.JSON `json:"metadata,omitempty"`
+
+	// Used to define a conversion Strategy for the secret keys
+	// +kubebuilder:default="None"
+	// +optional
+	ConversionStrategy PushSecretConversionStrategy `json:"conversionStrategy,omitempty"`
+}
+
+// PushSecretDataToMatch defines pattern matching for key selection.
+type PushSecretDataToMatch struct {
+	// Regexp matches keys by regular expression.
+	// If not specified, all keys are matched.
+	// +optional
+	RegExp string `json:"regexp,omitempty"`
+}
+
+// PushSecretRewrite defines how to transform secret keys before pushing.
+// +kubebuilder:validation:XValidation:rule="(has(self.regexp) && !has(self.transform)) || (!has(self.regexp) && has(self.transform))",message="exactly one of regexp or transform must be set"
+type PushSecretRewrite struct {
+	// Used to rewrite with regular expressions.
+	// +optional
+	Regexp *esv1.ExternalSecretRewriteRegexp `json:"regexp,omitempty"`
+
+	// Used to apply string transformation on the secrets.
+	// +optional
+	Transform *esv1.ExternalSecretRewriteTransform `json:"transform,omitempty"`
+}
+
 // PushSecretConditionType indicates the condition of the PushSecret.
 type PushSecretConditionType string
 

+ 84 - 0
apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go

@@ -240,6 +240,58 @@ func (in *PushSecretData) DeepCopy() *PushSecretData {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PushSecretDataTo) DeepCopyInto(out *PushSecretDataTo) {
+	*out = *in
+	if in.StoreRef != nil {
+		in, out := &in.StoreRef, &out.StoreRef
+		*out = new(PushSecretStoreRef)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Match != nil {
+		in, out := &in.Match, &out.Match
+		*out = new(PushSecretDataToMatch)
+		**out = **in
+	}
+	if in.Rewrite != nil {
+		in, out := &in.Rewrite, &out.Rewrite
+		*out = make([]PushSecretRewrite, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.Metadata != nil {
+		in, out := &in.Metadata, &out.Metadata
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretDataTo.
+func (in *PushSecretDataTo) DeepCopy() *PushSecretDataTo {
+	if in == nil {
+		return nil
+	}
+	out := new(PushSecretDataTo)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PushSecretDataToMatch) DeepCopyInto(out *PushSecretDataToMatch) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretDataToMatch.
+func (in *PushSecretDataToMatch) DeepCopy() *PushSecretDataToMatch {
+	if in == nil {
+		return nil
+	}
+	out := new(PushSecretDataToMatch)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PushSecretList) DeepCopyInto(out *PushSecretList) {
 	*out = *in
 	out.TypeMeta = in.TypeMeta
@@ -332,6 +384,31 @@ func (in *PushSecretRemoteRef) DeepCopy() *PushSecretRemoteRef {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PushSecretRewrite) DeepCopyInto(out *PushSecretRewrite) {
+	*out = *in
+	if in.Regexp != nil {
+		in, out := &in.Regexp, &out.Regexp
+		*out = new(externalsecretsv1.ExternalSecretRewriteRegexp)
+		**out = **in
+	}
+	if in.Transform != nil {
+		in, out := &in.Transform, &out.Transform
+		*out = new(externalsecretsv1.ExternalSecretRewriteTransform)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretRewrite.
+func (in *PushSecretRewrite) DeepCopy() *PushSecretRewrite {
+	if in == nil {
+		return nil
+	}
+	out := new(PushSecretRewrite)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PushSecretSecret) DeepCopyInto(out *PushSecretSecret) {
 	*out = *in
 	if in.Selector != nil {
@@ -399,6 +476,13 @@ func (in *PushSecretSpec) DeepCopyInto(out *PushSecretSpec) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.DataTo != nil {
+		in, out := &in.DataTo, &out.DataTo
+		*out = make([]PushSecretDataTo, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
 	if in.Template != nil {
 		in, out := &in.Template, &out.Template
 		*out = new(externalsecretsv1.ExternalSecretTemplate)

+ 157 - 0
config/crds/bases/external-secrets.io_clusterpushsecrets.yaml

@@ -174,6 +174,163 @@ spec:
                       - match
                       type: object
                     type: array
+                  dataTo:
+                    description: DataTo defines bulk push rules that expand source
+                      Secret keys into provider entries.
+                    items:
+                      description: PushSecretDataTo defines how to bulk-push secrets
+                        to providers without explicit per-key mappings.
+                      properties:
+                        conversionStrategy:
+                          default: None
+                          description: Used to define a conversion Strategy for the
+                            secret keys
+                          enum:
+                          - None
+                          - ReverseUnicode
+                          type: string
+                        match:
+                          description: |-
+                            Match pattern for selecting keys from the source Secret.
+                            If not specified, all keys are selected.
+                          properties:
+                            regexp:
+                              description: |-
+                                Regexp matches keys by regular expression.
+                                If not specified, all keys are matched.
+                              type: string
+                          type: object
+                        metadata:
+                          description: |-
+                            Metadata is metadata attached to the secret.
+                            The structure of metadata is provider specific, please look it up in the provider documentation.
+                          x-kubernetes-preserve-unknown-fields: true
+                        remoteKey:
+                          description: |-
+                            RemoteKey is the name of the single provider secret that will receive ALL
+                            matched keys bundled as a JSON object (e.g. {"DB_HOST":"...","DB_USER":"..."}).
+                            When set, per-key expansion is skipped and a single push is performed.
+                            The provider's store prefix (if any) is still prepended to this value.
+                            When not set, each matched key is pushed as its own individual provider secret.
+                          type: string
+                        rewrite:
+                          description: |-
+                            Rewrite operations to transform keys before pushing to the provider.
+                            Operations are applied sequentially.
+                          items:
+                            description: PushSecretRewrite defines how to transform
+                              secret keys before pushing.
+                            properties:
+                              regexp:
+                                description: Used to rewrite with regular expressions.
+                                properties:
+                                  source:
+                                    description: Used to define the regular expression
+                                      of a re.Compiler.
+                                    type: string
+                                  target:
+                                    description: Used to define the target pattern
+                                      of a ReplaceAll operation.
+                                    type: string
+                                required:
+                                - source
+                                - target
+                                type: object
+                              transform:
+                                description: Used to apply string transformation on
+                                  the secrets.
+                                properties:
+                                  template:
+                                    description: |-
+                                      Used to define the template to apply on the secret name.
+                                      `.value ` will specify the secret name in the template.
+                                    type: string
+                                required:
+                                - template
+                                type: object
+                            type: object
+                            x-kubernetes-validations:
+                            - message: exactly one of regexp or transform must be
+                                set
+                              rule: (has(self.regexp) && !has(self.transform)) ||
+                                (!has(self.regexp) && has(self.transform))
+                          type: array
+                        storeRef:
+                          description: StoreRef specifies which SecretStore to push
+                            to. Required.
+                          properties:
+                            kind:
+                              default: SecretStore
+                              description: Kind of the SecretStore resource (SecretStore
+                                or ClusterSecretStore)
+                              enum:
+                              - SecretStore
+                              - ClusterSecretStore
+                              type: string
+                            labelSelector:
+                              description: Optionally, sync to secret stores with
+                                label selector
+                              properties:
+                                matchExpressions:
+                                  description: matchExpressions is a list of label
+                                    selector requirements. The requirements are ANDed.
+                                  items:
+                                    description: |-
+                                      A label selector requirement is a selector that contains values, a key, and an operator that
+                                      relates the key and values.
+                                    properties:
+                                      key:
+                                        description: key is the label key that the
+                                          selector applies to.
+                                        type: string
+                                      operator:
+                                        description: |-
+                                          operator represents a key's relationship to a set of values.
+                                          Valid operators are In, NotIn, Exists and DoesNotExist.
+                                        type: string
+                                      values:
+                                        description: |-
+                                          values is an array of string values. If the operator is In or NotIn,
+                                          the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                          the values array must be empty. This array is replaced during a strategic
+                                          merge patch.
+                                        items:
+                                          type: string
+                                        type: array
+                                        x-kubernetes-list-type: atomic
+                                    required:
+                                    - key
+                                    - operator
+                                    type: object
+                                  type: array
+                                  x-kubernetes-list-type: atomic
+                                matchLabels:
+                                  additionalProperties:
+                                    type: string
+                                  description: |-
+                                    matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                    map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                    operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                  type: object
+                              type: object
+                              x-kubernetes-map-type: atomic
+                            name:
+                              description: Optionally, sync to the SecretStore of
+                                the given name
+                              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
+                          type: object
+                      type: object
+                      x-kubernetes-validations:
+                      - message: storeRef must specify either name or labelSelector
+                        rule: has(self.storeRef) && (has(self.storeRef.name) || has(self.storeRef.labelSelector))
+                      - message: 'remoteKey and rewrite are mutually exclusive: rewrite
+                          is only supported in per-key mode (without remoteKey)'
+                        rule: '!has(self.remoteKey) || !has(self.rewrite) || size(self.rewrite)
+                          == 0'
+                    type: array
                   deletionPolicy:
                     default: None
                     description: Deletion Policy to handle Secrets in the provider.

+ 156 - 0
config/crds/bases/external-secrets.io_pushsecrets.yaml

@@ -99,6 +99,162 @@ spec:
                   - match
                   type: object
                 type: array
+              dataTo:
+                description: DataTo defines bulk push rules that expand source Secret
+                  keys into provider entries.
+                items:
+                  description: PushSecretDataTo defines how to bulk-push secrets to
+                    providers without explicit per-key mappings.
+                  properties:
+                    conversionStrategy:
+                      default: None
+                      description: Used to define a conversion Strategy for the secret
+                        keys
+                      enum:
+                      - None
+                      - ReverseUnicode
+                      type: string
+                    match:
+                      description: |-
+                        Match pattern for selecting keys from the source Secret.
+                        If not specified, all keys are selected.
+                      properties:
+                        regexp:
+                          description: |-
+                            Regexp matches keys by regular expression.
+                            If not specified, all keys are matched.
+                          type: string
+                      type: object
+                    metadata:
+                      description: |-
+                        Metadata is metadata attached to the secret.
+                        The structure of metadata is provider specific, please look it up in the provider documentation.
+                      x-kubernetes-preserve-unknown-fields: true
+                    remoteKey:
+                      description: |-
+                        RemoteKey is the name of the single provider secret that will receive ALL
+                        matched keys bundled as a JSON object (e.g. {"DB_HOST":"...","DB_USER":"..."}).
+                        When set, per-key expansion is skipped and a single push is performed.
+                        The provider's store prefix (if any) is still prepended to this value.
+                        When not set, each matched key is pushed as its own individual provider secret.
+                      type: string
+                    rewrite:
+                      description: |-
+                        Rewrite operations to transform keys before pushing to the provider.
+                        Operations are applied sequentially.
+                      items:
+                        description: PushSecretRewrite defines how to transform secret
+                          keys before pushing.
+                        properties:
+                          regexp:
+                            description: Used to rewrite with regular expressions.
+                            properties:
+                              source:
+                                description: Used to define the regular expression
+                                  of a re.Compiler.
+                                type: string
+                              target:
+                                description: Used to define the target pattern of
+                                  a ReplaceAll operation.
+                                type: string
+                            required:
+                            - source
+                            - target
+                            type: object
+                          transform:
+                            description: Used to apply string transformation on the
+                              secrets.
+                            properties:
+                              template:
+                                description: |-
+                                  Used to define the template to apply on the secret name.
+                                  `.value ` will specify the secret name in the template.
+                                type: string
+                            required:
+                            - template
+                            type: object
+                        type: object
+                        x-kubernetes-validations:
+                        - message: exactly one of regexp or transform must be set
+                          rule: (has(self.regexp) && !has(self.transform)) || (!has(self.regexp)
+                            && has(self.transform))
+                      type: array
+                    storeRef:
+                      description: StoreRef specifies which SecretStore to push to.
+                        Required.
+                      properties:
+                        kind:
+                          default: SecretStore
+                          description: Kind of the SecretStore resource (SecretStore
+                            or ClusterSecretStore)
+                          enum:
+                          - SecretStore
+                          - ClusterSecretStore
+                          type: string
+                        labelSelector:
+                          description: Optionally, sync to secret stores with label
+                            selector
+                          properties:
+                            matchExpressions:
+                              description: matchExpressions is a list of label selector
+                                requirements. The requirements are ANDed.
+                              items:
+                                description: |-
+                                  A label selector requirement is a selector that contains values, a key, and an operator that
+                                  relates the key and values.
+                                properties:
+                                  key:
+                                    description: key is the label key that the selector
+                                      applies to.
+                                    type: string
+                                  operator:
+                                    description: |-
+                                      operator represents a key's relationship to a set of values.
+                                      Valid operators are In, NotIn, Exists and DoesNotExist.
+                                    type: string
+                                  values:
+                                    description: |-
+                                      values is an array of string values. If the operator is In or NotIn,
+                                      the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                      the values array must be empty. This array is replaced during a strategic
+                                      merge patch.
+                                    items:
+                                      type: string
+                                    type: array
+                                    x-kubernetes-list-type: atomic
+                                required:
+                                - key
+                                - operator
+                                type: object
+                              type: array
+                              x-kubernetes-list-type: atomic
+                            matchLabels:
+                              additionalProperties:
+                                type: string
+                              description: |-
+                                matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                operator is "In", and the values array contains only "value". The requirements are ANDed.
+                              type: object
+                          type: object
+                          x-kubernetes-map-type: atomic
+                        name:
+                          description: Optionally, sync to the SecretStore of the
+                            given name
+                          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
+                      type: object
+                  type: object
+                  x-kubernetes-validations:
+                  - message: storeRef must specify either name or labelSelector
+                    rule: has(self.storeRef) && (has(self.storeRef.name) || has(self.storeRef.labelSelector))
+                  - message: 'remoteKey and rewrite are mutually exclusive: rewrite
+                      is only supported in per-key mode (without remoteKey)'
+                    rule: '!has(self.remoteKey) || !has(self.rewrite) || size(self.rewrite)
+                      == 0'
+                type: array
               deletionPolicy:
                 default: None
                 description: Deletion Policy to handle Secrets in the provider.

+ 280 - 0
deploy/crds/bundle.yaml

@@ -1725,6 +1725,146 @@ spec:
                           - match
                         type: object
                       type: array
+                    dataTo:
+                      description: DataTo defines bulk push rules that expand source Secret keys into provider entries.
+                      items:
+                        description: PushSecretDataTo defines how to bulk-push secrets to providers without explicit per-key mappings.
+                        properties:
+                          conversionStrategy:
+                            default: None
+                            description: Used to define a conversion Strategy for the secret keys
+                            enum:
+                              - None
+                              - ReverseUnicode
+                            type: string
+                          match:
+                            description: |-
+                              Match pattern for selecting keys from the source Secret.
+                              If not specified, all keys are selected.
+                            properties:
+                              regexp:
+                                description: |-
+                                  Regexp matches keys by regular expression.
+                                  If not specified, all keys are matched.
+                                type: string
+                            type: object
+                          metadata:
+                            description: |-
+                              Metadata is metadata attached to the secret.
+                              The structure of metadata is provider specific, please look it up in the provider documentation.
+                            x-kubernetes-preserve-unknown-fields: true
+                          remoteKey:
+                            description: |-
+                              RemoteKey is the name of the single provider secret that will receive ALL
+                              matched keys bundled as a JSON object (e.g. {"DB_HOST":"...","DB_USER":"..."}).
+                              When set, per-key expansion is skipped and a single push is performed.
+                              The provider's store prefix (if any) is still prepended to this value.
+                              When not set, each matched key is pushed as its own individual provider secret.
+                            type: string
+                          rewrite:
+                            description: |-
+                              Rewrite operations to transform keys before pushing to the provider.
+                              Operations are applied sequentially.
+                            items:
+                              description: PushSecretRewrite defines how to transform secret keys before pushing.
+                              properties:
+                                regexp:
+                                  description: Used to rewrite with regular expressions.
+                                  properties:
+                                    source:
+                                      description: Used to define the regular expression of a re.Compiler.
+                                      type: string
+                                    target:
+                                      description: Used to define the target pattern of a ReplaceAll operation.
+                                      type: string
+                                  required:
+                                    - source
+                                    - target
+                                  type: object
+                                transform:
+                                  description: Used to apply string transformation on the secrets.
+                                  properties:
+                                    template:
+                                      description: |-
+                                        Used to define the template to apply on the secret name.
+                                        `.value ` will specify the secret name in the template.
+                                      type: string
+                                  required:
+                                    - template
+                                  type: object
+                              type: object
+                              x-kubernetes-validations:
+                                - message: exactly one of regexp or transform must be set
+                                  rule: (has(self.regexp) && !has(self.transform)) || (!has(self.regexp) && has(self.transform))
+                            type: array
+                          storeRef:
+                            description: StoreRef specifies which SecretStore to push to. Required.
+                            properties:
+                              kind:
+                                default: SecretStore
+                                description: Kind of the SecretStore resource (SecretStore or ClusterSecretStore)
+                                enum:
+                                  - SecretStore
+                                  - ClusterSecretStore
+                                type: string
+                              labelSelector:
+                                description: Optionally, sync to secret stores with label selector
+                                properties:
+                                  matchExpressions:
+                                    description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                                    items:
+                                      description: |-
+                                        A label selector requirement is a selector that contains values, a key, and an operator that
+                                        relates the key and values.
+                                      properties:
+                                        key:
+                                          description: key is the label key that the selector applies to.
+                                          type: string
+                                        operator:
+                                          description: |-
+                                            operator represents a key's relationship to a set of values.
+                                            Valid operators are In, NotIn, Exists and DoesNotExist.
+                                          type: string
+                                        values:
+                                          description: |-
+                                            values is an array of string values. If the operator is In or NotIn,
+                                            the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                            the values array must be empty. This array is replaced during a strategic
+                                            merge patch.
+                                          items:
+                                            type: string
+                                          type: array
+                                          x-kubernetes-list-type: atomic
+                                      required:
+                                        - key
+                                        - operator
+                                      type: object
+                                    type: array
+                                    x-kubernetes-list-type: atomic
+                                  matchLabels:
+                                    additionalProperties:
+                                      type: string
+                                    description: |-
+                                      matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                      map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                      operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                    type: object
+                                type: object
+                                x-kubernetes-map-type: atomic
+                              name:
+                                description: Optionally, sync to the SecretStore of the given name
+                                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
+                            type: object
+                        type: object
+                        x-kubernetes-validations:
+                          - message: storeRef must specify either name or labelSelector
+                            rule: has(self.storeRef) && (has(self.storeRef.name) || has(self.storeRef.labelSelector))
+                          - message: 'remoteKey and rewrite are mutually exclusive: rewrite is only supported in per-key mode (without remoteKey)'
+                            rule: '!has(self.remoteKey) || !has(self.rewrite) || size(self.rewrite) == 0'
+                      type: array
                     deletionPolicy:
                       default: None
                       description: Deletion Policy to handle Secrets in the provider.
@@ -13627,6 +13767,146 @@ spec:
                       - match
                     type: object
                   type: array
+                dataTo:
+                  description: DataTo defines bulk push rules that expand source Secret keys into provider entries.
+                  items:
+                    description: PushSecretDataTo defines how to bulk-push secrets to providers without explicit per-key mappings.
+                    properties:
+                      conversionStrategy:
+                        default: None
+                        description: Used to define a conversion Strategy for the secret keys
+                        enum:
+                          - None
+                          - ReverseUnicode
+                        type: string
+                      match:
+                        description: |-
+                          Match pattern for selecting keys from the source Secret.
+                          If not specified, all keys are selected.
+                        properties:
+                          regexp:
+                            description: |-
+                              Regexp matches keys by regular expression.
+                              If not specified, all keys are matched.
+                            type: string
+                        type: object
+                      metadata:
+                        description: |-
+                          Metadata is metadata attached to the secret.
+                          The structure of metadata is provider specific, please look it up in the provider documentation.
+                        x-kubernetes-preserve-unknown-fields: true
+                      remoteKey:
+                        description: |-
+                          RemoteKey is the name of the single provider secret that will receive ALL
+                          matched keys bundled as a JSON object (e.g. {"DB_HOST":"...","DB_USER":"..."}).
+                          When set, per-key expansion is skipped and a single push is performed.
+                          The provider's store prefix (if any) is still prepended to this value.
+                          When not set, each matched key is pushed as its own individual provider secret.
+                        type: string
+                      rewrite:
+                        description: |-
+                          Rewrite operations to transform keys before pushing to the provider.
+                          Operations are applied sequentially.
+                        items:
+                          description: PushSecretRewrite defines how to transform secret keys before pushing.
+                          properties:
+                            regexp:
+                              description: Used to rewrite with regular expressions.
+                              properties:
+                                source:
+                                  description: Used to define the regular expression of a re.Compiler.
+                                  type: string
+                                target:
+                                  description: Used to define the target pattern of a ReplaceAll operation.
+                                  type: string
+                              required:
+                                - source
+                                - target
+                              type: object
+                            transform:
+                              description: Used to apply string transformation on the secrets.
+                              properties:
+                                template:
+                                  description: |-
+                                    Used to define the template to apply on the secret name.
+                                    `.value ` will specify the secret name in the template.
+                                  type: string
+                              required:
+                                - template
+                              type: object
+                          type: object
+                          x-kubernetes-validations:
+                            - message: exactly one of regexp or transform must be set
+                              rule: (has(self.regexp) && !has(self.transform)) || (!has(self.regexp) && has(self.transform))
+                        type: array
+                      storeRef:
+                        description: StoreRef specifies which SecretStore to push to. Required.
+                        properties:
+                          kind:
+                            default: SecretStore
+                            description: Kind of the SecretStore resource (SecretStore or ClusterSecretStore)
+                            enum:
+                              - SecretStore
+                              - ClusterSecretStore
+                            type: string
+                          labelSelector:
+                            description: Optionally, sync to secret stores with label selector
+                            properties:
+                              matchExpressions:
+                                description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                                items:
+                                  description: |-
+                                    A label selector requirement is a selector that contains values, a key, and an operator that
+                                    relates the key and values.
+                                  properties:
+                                    key:
+                                      description: key is the label key that the selector applies to.
+                                      type: string
+                                    operator:
+                                      description: |-
+                                        operator represents a key's relationship to a set of values.
+                                        Valid operators are In, NotIn, Exists and DoesNotExist.
+                                      type: string
+                                    values:
+                                      description: |-
+                                        values is an array of string values. If the operator is In or NotIn,
+                                        the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                        the values array must be empty. This array is replaced during a strategic
+                                        merge patch.
+                                      items:
+                                        type: string
+                                      type: array
+                                      x-kubernetes-list-type: atomic
+                                  required:
+                                    - key
+                                    - operator
+                                  type: object
+                                type: array
+                                x-kubernetes-list-type: atomic
+                              matchLabels:
+                                additionalProperties:
+                                  type: string
+                                description: |-
+                                  matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                  map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                  operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                type: object
+                            type: object
+                            x-kubernetes-map-type: atomic
+                          name:
+                            description: Optionally, sync to the SecretStore of the given name
+                            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
+                        type: object
+                    type: object
+                    x-kubernetes-validations:
+                      - message: storeRef must specify either name or labelSelector
+                        rule: has(self.storeRef) && (has(self.storeRef.name) || has(self.storeRef.labelSelector))
+                      - message: 'remoteKey and rewrite are mutually exclusive: rewrite is only supported in per-key mode (without remoteKey)'
+                        rule: '!has(self.remoteKey) || !has(self.rewrite) || size(self.rewrite) == 0'
+                  type: array
                 deletionPolicy:
                   default: None
                   description: Deletion Policy to handle Secrets in the provider.

+ 198 - 0
docs/api/pushsecret.md

@@ -4,6 +4,7 @@ The `PushSecret` is namespaced and it describes what data should be pushed to th
 
 * tells the operator what secrets should be pushed by using `spec.selector`.
 * you can specify what secret keys should be pushed by using `spec.data`.
+* you can bulk-push secrets using pattern matching with `spec.dataTo`.
 * you can also template the resulting property values using [templating](#templating).
 
 ## Example
@@ -26,6 +27,203 @@ stringData:
   best-pokemon-dst: "PIKACHU is the really best!"
 ```
 
+## DataTo
+
+The `spec.dataTo` field enables bulk pushing of secrets without explicit per-key configuration. This is useful when you need to push multiple related secrets and want to avoid verbose YAML.
+
+### Basic Example
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-db-secrets
+spec:
+  secretStoreRefs:
+    - name: aws-secret-store
+  selector:
+    secret:
+      name: app-secrets
+  dataTo:
+    - storeRef:
+        name: aws-secret-store
+      match:
+        regexp: "^db-.*"  # Push all keys starting with "db-"
+      rewrite:
+        - regexp:
+            source: "^db-"
+            target: "myapp/database/"  # db-host -> myapp/database/host
+```
+
+### Fields
+
+#### `storeRef` (required)
+
+Specifies which SecretStore to push to. Each `dataTo` entry must include a `storeRef` to target a specific store.
+
+- **`name`** (string, optional): Name of the SecretStore to target.
+- **`labelSelector`** (object, optional): Select stores by label. Use either `name` or `labelSelector`, not both.
+- **`kind`** (string, optional): `SecretStore` or `ClusterSecretStore`. Defaults to `SecretStore`.
+
+```yaml
+dataTo:
+  # Target a specific store by name
+  - storeRef:
+      name: aws-secret-store
+
+  # Target stores by label
+  - storeRef:
+      labelSelector:
+        matchLabels:
+          env: production
+```
+
+!!! note "storeRef vs spec.data"
+    Unlike `spec.data` entries which can omit store targeting, every `dataTo` entry requires a `storeRef`.
+    This prevents accidental "push to all stores" behavior. The `storeRef` must reference a store
+    listed in `spec.secretStoreRefs`.
+
+#### `match` (optional)
+
+Defines which keys to select from the source Secret.
+
+- **`regexp`** (string, optional): Regular expression pattern to match keys. If omitted, all keys are matched.
+
+**Examples:**
+```yaml
+# Match all keys
+dataTo:
+  - storeRef:
+      name: my-store
+
+# Match keys starting with "db-"
+dataTo:
+  - storeRef:
+      name: my-store
+    match:
+      regexp: "^db-.*"
+
+# Match keys ending with "-key"
+dataTo:
+  - storeRef:
+      name: my-store
+    match:
+      regexp: ".*-key$"
+```
+
+#### `rewrite` (array, optional)
+
+Array of rewrite operations to transform key names. Operations are applied sequentially.
+
+Each rewrite can be either:
+
+**Regexp Rewrite:**
+```yaml
+rewrite:
+  - regexp:
+      source: "^db-"      # Regex pattern to match
+      target: "app/db/"   # Replacement string (supports capture groups like $1, $2)
+```
+
+**Transform Rewrite (Go Template):**
+{% raw %}
+```yaml
+rewrite:
+  - transform:
+      template: "secrets/{{ .value | upper }}"  # .value contains the key name
+```
+{% endraw %}
+
+**Chained Rewrites:**
+```yaml
+rewrite:
+  - regexp: {source: "^db-", target: ""}     # Remove "db-" prefix
+  - regexp: {source: "^", target: "prod/"}   # Add "prod/" prefix
+```
+
+#### `metadata` (object, optional)
+
+Provider-specific metadata to attach to all pushed secrets. Structure depends on the provider.
+
+```yaml
+dataTo:
+  - storeRef:
+      name: my-store
+    match:
+      regexp: "^db-.*"
+    metadata:
+      labels:
+        app: myapp
+        env: production
+```
+
+#### `conversionStrategy` (string, optional)
+
+Strategy for converting secret key names before matching and rewriting. The conversion is applied to keys (not values), and `match` patterns and `rewrite` operations operate on the converted key names. Default: `"None"`
+
+- `"None"`: No conversion
+- `"ReverseUnicode"`: Reverse Unicode escape sequences in key names (useful when paired with ExternalSecret's `Unicode` strategy)
+
+```yaml
+dataTo:
+  - storeRef:
+      name: my-store
+    conversionStrategy: ReverseUnicode
+```
+
+### Combining dataTo with data
+
+You can use both `dataTo` and `data` fields. Explicit `data` entries override `dataTo` for the same source key:
+
+```yaml
+spec:
+  secretStoreRefs:
+    - name: my-store
+  dataTo:
+    - storeRef:
+        name: my-store  # Push all keys with original names
+  data:
+    - match:
+        secretKey: db-host
+        remoteRef:
+          remoteKey: custom-db-host  # Override for db-host only
+```
+
+In this example, all keys are pushed via `dataTo`, but `db-host` uses the custom remote key from `data` instead.
+
+### Multiple dataTo Entries
+
+You can specify multiple `dataTo` entries with different patterns:
+
+```yaml
+spec:
+  secretStoreRefs:
+    - name: my-store
+  dataTo:
+    # Push db-* keys with database/ prefix
+    - storeRef:
+        name: my-store
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        - regexp: {source: "^db-", target: "database/"}
+    # Push api-* keys with api/ prefix
+    - storeRef:
+        name: my-store
+      match:
+        regexp: "^api-.*"
+      rewrite:
+        - regexp: {source: "^api-", target: "api/"}
+```
+
+### Error Handling
+
+- **Invalid regular expression**: PushSecret enters error state with details in status
+- **Duplicate remote keys**: Operation fails if rewrites produce duplicate keys
+- **No matching keys**: Warning logged, PushSecret remains Ready
+
+See the [PushSecret dataTo guide](../guides/pushsecret-datato.md) for more examples and use cases.
+
 ## Template
 
 When the controller reconciles the `PushSecret` it will use the `spec.template` as a blueprint to construct a new property.

+ 221 - 3
docs/api/spec.md

@@ -4551,7 +4551,8 @@ list during merge operations.</p>
 </h3>
 <p>
 (<em>Appears on:</em>
-<a href="#external-secrets.io/v1.ExternalSecretRewrite">ExternalSecretRewrite</a>)
+<a href="#external-secrets.io/v1.ExternalSecretRewrite">ExternalSecretRewrite</a>, 
+<a href="#external-secrets.io/v1alpha1.PushSecretRewrite">PushSecretRewrite</a>)
 </p>
 <p>
 <p>ExternalSecretRewriteRegexp defines configuration for rewriting secrets using regular expressions.</p>
@@ -4592,7 +4593,8 @@ string
 </h3>
 <p>
 (<em>Appears on:</em>
-<a href="#external-secrets.io/v1.ExternalSecretRewrite">ExternalSecretRewrite</a>)
+<a href="#external-secrets.io/v1.ExternalSecretRewrite">ExternalSecretRewrite</a>, 
+<a href="#external-secrets.io/v1alpha1.PushSecretRewrite">PushSecretRewrite</a>)
 </p>
 <p>
 <p>ExternalSecretRewriteTransform defines configuration for transforming secrets using templates.</p>
@@ -13358,11 +13360,26 @@ PushSecretSelector
 </em>
 </td>
 <td>
+<em>(Optional)</em>
 <p>Secret Data that should be pushed to providers</p>
 </td>
 </tr>
 <tr>
 <td>
+<code>dataTo</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">
+[]PushSecretDataTo
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>DataTo defines bulk push rules that expand source Secret keys into provider entries.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>template</code></br>
 <em>
 <a href="#external-secrets.io/v1.ExternalSecretTemplate">
@@ -13417,7 +13434,8 @@ PushSecretStatus
 (<code>string</code> alias)</p></h3>
 <p>
 (<em>Appears on:</em>
-<a href="#external-secrets.io/v1alpha1.PushSecretData">PushSecretData</a>)
+<a href="#external-secrets.io/v1alpha1.PushSecretData">PushSecretData</a>, 
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">PushSecretDataTo</a>)
 </p>
 <p>
 <p>PushSecretConversionStrategy defines how secret values are converted when pushed to providers.</p>
@@ -13496,6 +13514,143 @@ PushSecretConversionStrategy
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1alpha1.PushSecretDataTo">PushSecretDataTo
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1alpha1.PushSecretSpec">PushSecretSpec</a>)
+</p>
+<p>
+<p>PushSecretDataTo defines how to bulk-push secrets to providers without explicit per-key mappings.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>storeRef</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretStoreRef">
+PushSecretStoreRef
+</a>
+</em>
+</td>
+<td>
+<p>StoreRef specifies which SecretStore to push to. Required.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>remoteKey</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>RemoteKey is the name of the single provider secret that will receive ALL
+matched keys bundled as a JSON object (e.g. {&ldquo;DB_HOST&rdquo;:&ldquo;&hellip;&rdquo;,&ldquo;DB_USER&rdquo;:&ldquo;&hellip;&rdquo;}).
+When set, per-key expansion is skipped and a single push is performed.
+The provider&rsquo;s store prefix (if any) is still prepended to this value.
+When not set, each matched key is pushed as its own individual provider secret.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>match</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataToMatch">
+PushSecretDataToMatch
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Match pattern for selecting keys from the source Secret.
+If not specified, all keys are selected.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>rewrite</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretRewrite">
+[]PushSecretRewrite
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Rewrite operations to transform keys before pushing to the provider.
+Operations are applied sequentially.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>metadata</code></br>
+<em>
+k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.JSON
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Metadata is metadata attached to the secret.
+The structure of metadata is provider specific, please look it up in the provider documentation.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>conversionStrategy</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretConversionStrategy">
+PushSecretConversionStrategy
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Used to define a conversion Strategy for the secret keys</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1alpha1.PushSecretDataToMatch">PushSecretDataToMatch
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">PushSecretDataTo</a>)
+</p>
+<p>
+<p>PushSecretDataToMatch defines pattern matching for key selection.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>regexp</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Regexp matches keys by regular expression.
+If not specified, all keys are matched.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1alpha1.PushSecretDeletionPolicy">PushSecretDeletionPolicy
 (<code>string</code> alias)</p></h3>
 <p>
@@ -13647,6 +13802,53 @@ string
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1alpha1.PushSecretRewrite">PushSecretRewrite
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">PushSecretDataTo</a>)
+</p>
+<p>
+<p>PushSecretRewrite defines how to transform secret keys before pushing.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>regexp</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretRewriteRegexp">
+ExternalSecretRewriteRegexp
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Used to rewrite with regular expressions.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>transform</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretRewriteTransform">
+ExternalSecretRewriteTransform
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Used to apply string transformation on the secrets.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1alpha1.PushSecretSecret">PushSecretSecret
 </h3>
 <p>
@@ -13834,11 +14036,26 @@ PushSecretSelector
 </em>
 </td>
 <td>
+<em>(Optional)</em>
 <p>Secret Data that should be pushed to providers</p>
 </td>
 </tr>
 <tr>
 <td>
+<code>dataTo</code></br>
+<em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">
+[]PushSecretDataTo
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>DataTo defines bulk push rules that expand source Secret keys into provider entries.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>template</code></br>
 <em>
 <a href="#external-secrets.io/v1.ExternalSecretTemplate">
@@ -14008,6 +14225,7 @@ Kubernetes meta/v1.Time
 </h3>
 <p>
 (<em>Appears on:</em>
+<a href="#external-secrets.io/v1alpha1.PushSecretDataTo">PushSecretDataTo</a>, 
 <a href="#external-secrets.io/v1alpha1.PushSecretSpec">PushSecretSpec</a>)
 </p>
 <p>

+ 175 - 0
docs/design/pushsecret-datato.md

@@ -0,0 +1,175 @@
+# Design: PushSecret `dataTo`
+
+**Author:** Mohamed Rekiba
+**Date:** 2026-01-20
+**Status:** Proposed
+**Related Issue:** [#5221 — Revamp PushSecret](https://github.com/external-secrets/external-secrets/issues/5221)
+**PR:** [#5850](https://github.com/external-secrets/external-secrets/pull/5850)
+
+---
+
+## Motivation
+
+PushSecret today requires an explicit `data` entry for every key you want to push. This creates three problems:
+
+1. **Sync drift** — adding a key to a Kubernetes Secret without adding a matching PushSecret entry means that key silently never reaches the provider.
+2. **Config verbosity** — a Secret with 20+ keys needs 20+ lines of boilerplate YAML that all look the same.
+3. **Maintenance burden** — keys evolve alongside application code; keeping PushSecret config in sync is easy to forget.
+
+ExternalSecret already solved the equivalent inbound problem with `dataFrom` (bulk-pull from providers). PushSecret has no equivalent outbound mechanism.
+
+### Workarounds today
+
+| Workaround | Drawback |
+|---|---|
+| Enumerate every key in `spec.data` | Verbose; falls out of sync when keys change |
+| External tooling (scripts, Helm helpers) to generate PushSecret YAML | Adds build-time dependency; not declarative |
+| One PushSecret per key | Explodes resource count; harder to reason about |
+
+None of these are satisfactory for teams with dynamic secret sets that change frequently.
+
+## Goals
+
+1. Enable bulk pushing of all (or a filtered subset of) keys from a Kubernetes Secret to a provider without per-key enumeration.
+2. Support key transformation so source key names can be rewritten before reaching the provider.
+3. Scope each bulk-push entry to a specific store to prevent accidental cross-store pushes.
+4. Coexist cleanly with explicit `data` entries, with explicit entries taking precedence.
+5. Align PushSecret's capabilities with ExternalSecret's `dataFrom` where the push direction makes sense.
+
+## Non-goals
+
+- Replacing `spec.data` — explicit per-key control remains available and takes priority.
+- Implementing ExternalSecret's `Extract` or `Find` — the source is always the Kubernetes Secret selected by `spec.selector`, not a provider query.
+- Implementing the `Merge` rewrite — PushSecret has a single source, so there is nothing to merge.
+- Adding a `RefreshPolicy` (tracked separately in #5221).
+- Changing the provider interface (`SecretsClient`).
+
+## Design
+
+### API shape
+
+```go
+type PushSecretDataTo struct {
+    StoreRef           *PushSecretStoreRef          // required — which store to push to
+    RemoteKey          string                        // optional — bundle mode target
+    Match              *PushSecretDataToMatch        // optional — regexp key filter
+    Rewrite            []PushSecretRewrite           // optional — key transformations
+    Metadata           *apiextensionsv1.JSON         // optional — provider-specific metadata
+    ConversionStrategy PushSecretConversionStrategy  // optional — key name encoding
+}
+
+type PushSecretDataToMatch struct {
+    RegExp string  // empty or nil = match all keys
+}
+
+type PushSecretRewrite struct {
+    // Exactly one of:
+    Regexp    *esv1.ExternalSecretRewriteRegexp
+    Transform *esv1.ExternalSecretRewriteTransform
+}
+```
+
+### Why `dataTo`?
+
+The name mirrors ExternalSecret's `dataFrom`:
+
+- `dataFrom` = pull data **from** the provider into K8s
+- `dataTo` = push data **to** the provider from K8s
+
+The direction is unambiguous and the symmetry aids discoverability.
+
+### Two operating modes
+
+| Mode | Trigger | Behavior | Use case |
+|---|---|---|---|
+| **Per-key** | `remoteKey` not set | Each matched key becomes its own provider secret/variable | Env-var providers (GitHub Actions, Doppler) |
+| **Bundle** | `remoteKey` set | All matched keys bundled as JSON into one named provider secret | Named-secret providers (AWS SM, Vault, Azure KV, GCP SM) |
+
+Bundle mode and `rewrite` are mutually exclusive — when keys are bundled into a JSON object, the key names inside the JSON are the source names (after conversion), not individually rewritten provider paths.
+
+### Comparison with ExternalSecret `dataFrom`
+
+| Aspect | ExternalSecret `dataFrom` | PushSecret `dataTo` |
+|---|---|---|
+| **Direction** | Provider → K8s | K8s → Provider |
+| **Source** | Provider (Extract, Find, GeneratorRef) | K8s Secret (via `spec.selector`) |
+| **Source discovery** | Find by tags/name in provider | Filter by regexp on K8s key names |
+| **Key transformation** | Regexp, Transform, Merge | Regexp, Transform (no Merge — single source) |
+| **Store targeting** | Single `secretStoreRef` per ES | Per-entry `storeRef` (required) |
+| **Merge strategy** | Multiple dataFrom merged into one Secret | `dataTo` + explicit `data` merged (explicit wins) |
+
+### Why simpler than `dataFrom`?
+
+- **No `Extract` or `Find`** — the source is always the K8s Secret; there's nothing to query.
+- **No `Merge` rewrite** — single source means no multi-source key collisions to resolve.
+- **Per-entry store scoping** — prevents "push to all stores" footgun; each entry declares its target.
+
+### Rewrite type reuse
+
+`PushSecretRewrite` reuses the inner types from ExternalSecret:
+
+- `esv1.ExternalSecretRewriteRegexp` (source/target regexp replacement)
+- `esv1.ExternalSecretRewriteTransform` (Go template transformation)
+
+This avoids type duplication while intentionally excluding `ExternalSecretRewriteMerge` which doesn't apply to the push direction.
+
+The controller uses `rewriteWithKeyMapping()` instead of `esutils.RewriteMap()` because PushSecret needs a **source → destination key mapping** for conflict resolution and status tracking. `RewriteMap` operates on `map[string][]byte` (transforming the map in place), while PushSecret needs to track which original key produced which remote key. This divergence is intentional and documented.
+
+### `storeRef` is required
+
+Every `dataTo` entry must specify a `storeRef` with either `name` or `labelSelector`. This was added after maintainer feedback to prevent accidentally pushing to all stores when `secretStoreRefs` contains multiple entries.
+
+### Metadata handling
+
+Each `dataTo` entry carries its own `Metadata` field. Since different providers need structurally different metadata (e.g., AWS tags vs. Azure properties), and each entry targets a specific store via `storeRef`, users can provide per-store metadata naturally by having separate `dataTo` entries per store.
+
+### Feature interactions
+
+| Feature | Interaction with `dataTo` |
+|---|---|
+| **Template** (`spec.template`) | Template is applied *before* `dataTo` expansion. `dataTo` matches against template output keys. |
+| **UpdatePolicy=IfNotExists** | Honored per-entry: if the remote secret already exists, the push is skipped. |
+| **DeletionPolicy=Delete** | All `dataTo`-expanded entries are tracked in `status.syncedPushSecrets`. When the source Secret is deleted, all tracked provider secrets are cleaned up. |
+| **ConversionStrategy** | Applied *before* key matching and rewriting, so regexp patterns see converted key names. |
+| **Explicit `data`** | Explicit entries override `dataTo` for the same source key. Comparison uses original (unconverted) K8s key names. |
+
+### Edge cases
+
+| Scenario | Behavior |
+|---|---|
+| Empty match pattern | Matches all keys |
+| No keys match | Info log, continue (not an error) |
+| Invalid regexp | PushSecret enters error state with details in status |
+| Duplicate remote keys (within or across entries) | Reconciliation fails listing all conflicting sources |
+| Explicit `data` for same source key | `data` wins; `dataTo` entry is dropped |
+| Invalid template | Fail with template parsing error |
+| Both `regexp` and `transform` on a rewrite | Blocked by CRD XValidation |
+| `storeRef` not in `secretStoreRefs` | Validation error |
+| Source Secret deleted + DeletionPolicy=Delete | Provider secrets cleaned up via status tracking |
+| `remoteKey` + `rewrite` on same entry | `rewrite` is ignored in bundle mode (documented) |
+
+## Alternatives considered
+
+### Alternative 1: Reuse ExternalSecret's `dataFrom` field name
+
+Rejected because `dataFrom` implies pulling *from* a source, while PushSecret pushes *to* a destination. Using `dataFrom` on PushSecret would be semantically confusing.
+
+### Alternative 2: Implicit "push all keys" when `data` is empty
+
+Rejected because implicit behavior is dangerous for secrets. A typo or misconfiguration could push keys to unintended stores. Explicit opt-in via `dataTo` is safer.
+
+### Alternative 3: Provider-keyed metadata map
+
+Instead of per-entry metadata, use a map keyed by provider type. Rejected because `storeRef` per entry already enables per-store metadata naturally, and a provider-keyed map would require the API to enumerate provider types.
+
+### Alternative 4: Type alias to `ExternalSecretRewrite`
+
+Using a direct type alias would include `Merge` which doesn't apply to PushSecret. A new struct with shared inner types provides the right subset.
+
+## Backwards compatibility
+
+- `dataTo` is fully optional — existing PushSecrets work exactly as before.
+- `data` field semantics are unchanged.
+- No breaking changes to the v1alpha1 API (purely additive).
+- No changes to the provider interface.
+- All pre-existing tests continue to pass.

+ 579 - 0
docs/examples/pushsecret-datato.md

@@ -0,0 +1,579 @@
+# PushSecret dataTo Examples
+
+This page provides practical examples of using the `dataTo` field in PushSecret to bulk-push secrets to external providers.
+
+## Prerequisites
+
+Before using these examples, ensure you have:
+
+- External Secrets Operator installed in your cluster
+- A configured SecretStore (or ClusterSecretStore)
+- A source Kubernetes Secret with the data you want to push
+
+## Example 1: Basic Database Credentials Push
+
+Push all database-related secrets with organized naming.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: db-credentials
+  namespace: myapp
+type: Opaque
+stringData:
+  db-host: "prod-db.example.com"
+  db-port: "5432"
+  db-username: "app_user"
+  db-password: "super-secret-password"
+  db-database: "myapp_db"
+  db-ssl-mode: "require"
+```
+
+**PushSecret with dataTo:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-db-credentials
+  namespace: myapp
+spec:
+  refreshInterval: 1h
+  secretStoreRefs:
+    - name: aws-secrets-manager
+      kind: SecretStore
+  selector:
+    secret:
+      name: db-credentials
+  dataTo:
+    - storeRef:
+        name: aws-secrets-manager
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        - regexp:
+            source: "^db-"
+            target: "myapp/production/database/"
+```
+
+**Result in AWS Secrets Manager:**
+- `myapp/production/database/host`
+- `myapp/production/database/port`
+- `myapp/production/database/username`
+- `myapp/production/database/password`
+- `myapp/production/database/database`
+- `myapp/production/database/ssl-mode`
+
+## Example 2: Multi-Environment Configuration
+
+Push the same secrets to different environments with different prefixes.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: app-config
+  namespace: myapp
+type: Opaque
+stringData:
+  api-key: "abc123xyz"
+  api-secret: "secret456"
+  redis-url: "redis://cache:6379"
+  postgres-url: "postgres://db:5432/mydb"
+```
+
+**Development Environment:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-dev-config
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: vault-dev
+  selector:
+    secret:
+      name: app-config
+  dataTo:
+    - storeRef:
+        name: vault-dev
+      rewrite:
+        - regexp:
+            source: "^"
+            target: "dev/myapp/"
+```
+
+**Production Environment:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-prod-config
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: vault-prod
+  selector:
+    secret:
+      name: app-config
+  dataTo:
+    - storeRef:
+        name: vault-prod
+      rewrite:
+        - regexp:
+            source: "^"
+            target: "prod/myapp/"
+```
+
+## Example 3: Organizing Secrets by Category
+
+Push different types of secrets to organized paths.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: mixed-secrets
+  namespace: myapp
+type: Opaque
+stringData:
+  db-host: "database.local"
+  db-password: "dbpass"
+  api-github-token: "ghp_xxx"
+  api-stripe-key: "sk_live_xxx"
+  tls-cert: "-----BEGIN CERTIFICATE-----"
+  tls-key: "-----BEGIN PRIVATE KEY-----"
+```
+
+**PushSecret with Multiple Patterns:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: organize-secrets
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: vault-store
+  selector:
+    secret:
+      name: mixed-secrets
+  dataTo:
+    # Database credentials -> config/database/*
+    - storeRef:
+        name: vault-store
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        - regexp:
+            source: "^db-"
+            target: "config/database/"
+
+    # API keys -> config/api/*
+    - storeRef:
+        name: vault-store
+      match:
+        regexp: "^api-.*"
+      rewrite:
+        - regexp:
+            source: "^api-"
+            target: "config/api/"
+
+    # TLS certificates -> config/tls/*
+    - storeRef:
+        name: vault-store
+      match:
+        regexp: "^tls-.*"
+      rewrite:
+        - regexp:
+            source: "^tls-"
+            target: "config/tls/"
+```
+
+**Result:**
+- `config/database/host`
+- `config/database/password`
+- `config/api/github-token`
+- `config/api/stripe-key`
+- `config/tls/cert`
+- `config/tls/key`
+
+## Example 4: Template Transformation
+
+Use Go templates to transform key names with advanced logic.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: service-keys
+  namespace: myapp
+type: Opaque
+stringData:
+  payment-gateway-key: "pk_xxx"
+  email-service-key: "es_xxx"
+  storage-service-key: "ss_xxx"
+```
+
+**PushSecret with Template:**
+{% raw %}
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-service-keys
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: gcp-secret-manager
+  selector:
+    secret:
+      name: service-keys
+  dataTo:
+    - storeRef:
+        name: gcp-secret-manager
+      rewrite:
+        - transform:
+            template: "services/{{ .value | upper | replace \"-\" \"_\" }}"
+```
+{% endraw %}
+
+**Result:**
+- `services/PAYMENT_GATEWAY_KEY`
+- `services/EMAIL_SERVICE_KEY`
+- `services/STORAGE_SERVICE_KEY`
+
+## Example 5: Chained Transformations
+
+Apply multiple transformations sequentially for complex key restructuring.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: legacy-secrets
+  namespace: myapp
+type: Opaque
+stringData:
+  old-db-primary-host: "db1.old.local"
+  old-db-replica-host: "db2.old.local"
+  old-cache-redis-url: "redis://old-cache:6379"
+```
+
+**PushSecret with Chained Rewrites:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: migrate-legacy-secrets
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: aws-secrets-manager
+  selector:
+    secret:
+      name: legacy-secrets
+  dataTo:
+    - storeRef:
+        name: aws-secrets-manager
+      rewrite:
+        # First: Remove "old-" prefix
+        - regexp:
+            source: "^old-"
+            target: ""
+        # Second: Add "migrated/" prefix
+        - regexp:
+            source: "^"
+            target: "migrated/"
+        # Third: Replace hyphens with slashes for hierarchy
+        - regexp:
+            source: "-"
+            target: "/"
+```
+
+**Result:**
+- `migrated/db/primary/host`
+- `migrated/db/replica/host`
+- `migrated/cache/redis/url`
+
+## Example 6: Override Specific Keys
+
+Use both dataTo and explicit data to handle exceptions.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: app-secrets
+  namespace: myapp
+type: Opaque
+stringData:
+  db-host: "database.local"
+  db-port: "5432"
+  db-user: "app"
+  db-password: "secret123"
+  db-admin-password: "admin-secret"  # Should go to different location
+```
+
+**PushSecret with Override:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-with-override
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: vault-store
+  selector:
+    secret:
+      name: app-secrets
+  # Push all db-* keys to app/database/*
+  dataTo:
+    - storeRef:
+        name: vault-store
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        - regexp:
+            source: "^db-"
+            target: "app/database/"
+
+  # Except db-admin-password which goes to admin/
+  data:
+    - match:
+        secretKey: db-admin-password
+        remoteRef:
+          remoteKey: admin/database/password
+```
+
+**Result:**
+- `app/database/host` (from dataTo)
+- `app/database/port` (from dataTo)
+- `app/database/user` (from dataTo)
+- `app/database/password` (from dataTo)
+- `admin/database/password` (from explicit data override)
+
+## Example 7: AWS Secrets Manager with Metadata
+
+Push secrets with AWS-specific metadata tags.
+
+**PushSecret with Metadata:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-with-aws-tags
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: aws-secrets-manager
+  selector:
+    secret:
+      name: app-config
+  dataTo:
+    - storeRef:
+        name: aws-secrets-manager
+      match:
+        regexp: "^prod-.*"
+      rewrite:
+        - regexp:
+            source: "^prod-"
+            target: "myapp/prod/"
+      metadata:
+        tags:
+          - key: Environment
+            value: production
+          - key: Application
+            value: myapp
+          - key: ManagedBy
+            value: external-secrets-operator
+```
+
+## Example 8: Vault with KV Version 2
+
+Push secrets to HashiCorp Vault KV v2 engine with proper paths.
+
+**Source Secret:**
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: vault-secrets
+  namespace: myapp
+type: Opaque
+stringData:
+  service-a-key: "key-a"
+  service-b-key: "key-b"
+  shared-secret: "shared"
+```
+
+**PushSecret for Vault:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-to-vault
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: vault-kv-v2
+  selector:
+    secret:
+      name: vault-secrets
+  dataTo:
+    # Service-specific secrets
+    - storeRef:
+        name: vault-kv-v2
+      match:
+        regexp: "^service-.*-key$"
+      rewrite:
+        - regexp:
+            source: "^service-(.*)-key$"
+            target: "services/$1/api-key"  # Use capture group
+
+    # Shared secrets
+    - storeRef:
+        name: vault-kv-v2
+      match:
+        regexp: "^shared-.*"
+      rewrite:
+        - regexp:
+            source: "^shared-"
+            target: "shared/"
+```
+
+**Result:**
+- `services/a/api-key`
+- `services/b/api-key`
+- `shared/secret`
+
+## Example 9: Azure Key Vault
+
+Push secrets to Azure Key Vault with naming constraints (alphanumeric and hyphens only).
+
+**PushSecret for Azure:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-to-azure
+  namespace: myapp
+spec:
+  secretStoreRefs:
+    - name: azure-key-vault
+  selector:
+    secret:
+      name: app-secrets
+  dataTo:
+    - storeRef:
+        name: azure-key-vault
+      rewrite:
+        # Azure Key Vault only allows alphanumeric and hyphens
+        # Convert underscores to hyphens
+        - regexp:
+            source: "_"
+            target: "-"
+        # Add prefix
+        - regexp:
+            source: "^"
+            target: "myapp-"
+```
+
+## Example 10: Migration from One Provider to Another
+
+Backup secrets from AWS to GCP while maintaining structure.
+
+**Step 1: Pull from AWS using ExternalSecret:**
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+  name: pull-from-aws
+  namespace: backup
+spec:
+  refreshInterval: 1h
+  secretStoreRef:
+    name: aws-secrets-manager
+  target:
+    name: aws-backup-secrets
+  dataFrom:
+    - find:
+        name:
+          regexp: "^myapp/.*"
+```
+
+**Step 2: Push to GCP with dataTo:**
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: backup-to-gcp
+  namespace: backup
+spec:
+  secretStoreRefs:
+    - name: gcp-secret-manager
+  selector:
+    secret:
+      name: aws-backup-secrets
+  dataTo:
+    - storeRef:
+        name: gcp-secret-manager
+      rewrite:
+        # Maintain structure but add backup prefix
+        - regexp:
+            source: "^"
+            target: "backup-from-aws/"
+```
+
+## Troubleshooting
+
+### Check PushSecret Status
+
+```bash
+kubectl get pushsecret <name> -n <namespace> -o yaml
+```
+
+Look for the `status.conditions` field for error messages.
+
+### View Synced Secrets
+
+```bash
+kubectl get pushsecret <name> -n <namespace> -o jsonpath='{.status.syncedPushSecrets}' | jq
+```
+
+### Common Issues
+
+**1. No keys matched:**
+- Verify the source Secret has keys matching your pattern
+- Check regexp syntax: `kubectl get secret <name> -o jsonpath='{.data}' | jq 'keys'`
+
+**2. Invalid regexp error:**
+- Validate your regexp using an online regexp tester
+- Ensure special characters are properly escaped
+
+**3. Duplicate remote keys:**
+- Check if your rewrites produce unique keys
+- Adjust patterns or use explicit data overrides
+
+## Best Practices
+
+1. **Start with match-all to verify**: Test with `dataTo: [{storeRef: {name: your-store}}]` first
+2. **Test regexp patterns**: Use `kubectl get secret -o jsonpath='{.data}' | jq 'keys'`
+3. **Use descriptive patterns**: Make regexp patterns self-documenting
+4. **Monitor status**: Check PushSecret status after creation
+5. **Version control**: Keep PushSecret manifests in git
+6. **Document transformations**: Add comments explaining complex rewrites
+
+## See Also
+
+- [PushSecret dataTo Guide](../guides/pushsecret-datato.md)
+- [PushSecret API Reference](../api/pushsecret.md)
+- [Provider Documentation](../provider/)

+ 376 - 0
docs/guides/pushsecret-datato.md

@@ -0,0 +1,376 @@
+# PushSecret dataTo
+
+The `dataTo` field in PushSecret enables bulk pushing of secrets without requiring explicit
+per-key configuration. Instead of listing every key manually in `data`, you point `dataTo` at a
+store and optionally filter or transform the keys that get pushed.
+
+## Overview
+
+`dataTo` supports two distinct modes. Which one to use depends entirely on your **provider's
+secret model**:
+
+| Mode | When to use | `remoteKey` |
+|---|---|---|
+| **Per-key** | Provider uses one named variable/entry per secret (GitHub Actions, Doppler) | not set |
+| **Bundle** | Provider stores structured config as a single named secret (AWS SM, Azure KV, GCP SM, Vault) | **required** |
+
+## Choosing the right mode
+
+### Per-key mode (env-var providers)
+
+Providers like **GitHub Actions** and **Doppler** model secrets as individual named
+variables — each key in your Kubernetes Secret maps to exactly one variable in the provider.
+Do **not** set `remoteKey` in this case; the key names themselves become the provider variable names.
+
+```yaml
+# GitHub Actions / Doppler — one variable per key
+dataTo:
+  - storeRef:
+      name: github-store
+    # no remoteKey — each K8s key becomes its own GitHub secret
+    match:
+      regexp: "^APP_"
+```
+
+Result in GitHub Actions (assuming the K8s Secret has `APP_TOKEN` and `APP_ENV`):
+```text
+APP_TOKEN → value of APP_TOKEN
+APP_ENV   → value of APP_ENV
+```
+
+### Bundle mode (named-secret providers)
+
+Providers like **AWS Secrets Manager**, **Azure Key Vault**, **GCP Secret Manager**, and
+**HashiCorp Vault** model secrets as a single named object that holds a JSON payload. Use
+`remoteKey` to name that object — all matched keys are bundled into it as a JSON object.
+
+```yaml
+# AWS SM / Azure KV / GCP SM / Vault — all keys → one named secret
+dataTo:
+  - storeRef:
+      name: aws-store
+    remoteKey: my-app/config    # the AWS Secrets Manager secret name
+    match:
+      regexp: "^DB_"
+```
+
+Result in AWS Secrets Manager:
+```text
+my-app/config → {"DB_HOST":"localhost","DB_USER":"admin","DB_PASS":"s3cr3t"}
+```
+
+!!! warning "Without `remoteKey` on named-secret providers"
+    If you omit `remoteKey` on a provider like AWS Secrets Manager, `dataTo` falls back to
+    per-key mode and creates **one AWS secret per matched key**
+    (`DB_HOST`, `DB_USER`, `DB_PASS` each become separate secrets).
+    This is rarely what you want on AWS — always set `remoteKey` when targeting AWS SM,
+    Azure KV, GCP SM, or Vault.
+
+## Provider reference
+
+| Provider | Secret model | Use `remoteKey`? | Notes |
+|---|---|---|---|
+| AWS Secrets Manager | Named secret (JSON) | **Yes** | `remoteKey` = secret name; store `prefix` is prepended |
+| AWS Parameter Store | Named parameter | **Yes** | `remoteKey` = parameter path |
+| Azure Key Vault | Named secret/key/cert | **Yes** | `remoteKey` = object name |
+| GCP Secret Manager | Named secret | **Yes** | `remoteKey` = secret ID |
+| HashiCorp Vault | Named path (JSON) | **Yes** | `remoteKey` = Vault path |
+| Oracle Vault | Named secret | **Yes** | `remoteKey` = secret name |
+| Kubernetes | Named secret | **Yes** | `remoteKey` = target Secret name |
+| Bitwarden | Named item | **Yes** | `remoteKey` = item key |
+| GitHub Actions | Env-var (one per key) | **No** | Key name = Actions secret name |
+| Doppler | Env-var (one per key) | **No** | Key name = Doppler variable name |
+| Webhook | Configurable | Depends | Check your webhook implementation |
+
+## Examples by provider
+
+### AWS Secrets Manager
+
+!!! warning "Prefix + remoteKey = concatenated name"
+    The AWS SecretStore `prefix` is **prepended** to every `remoteKey`. If your store has
+    `prefix: myapp/` and your `dataTo` has `remoteKey: db-config`, the resulting AWS secret
+    name is `myapp/db-config` — not `db-config`.
+
+    A common mistake is setting `prefix: secrets-sync-temp/` and `remoteKey: secrets-sync-temp`,
+    which creates `secrets-sync-temp/secrets-sync-temp` — not `secrets-sync-temp`.
+    If you want the secret name to be exactly `secrets-sync-temp`, either remove the prefix
+    from the store or set `remoteKey` to the suffix portion only.
+
+!!! tip "Make the value visible in the AWS Console"
+    By default ESO stores secret values as **binary** (`SecretBinary`). The AWS Console
+    may show binary secrets as blank or unreadable. Add `secretPushFormat: string` to the
+    `metadata` to store the JSON as a readable `SecretString` instead.
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: aws-store
+spec:
+  provider:
+    aws:
+      service: SecretsManager
+      region: us-east-1
+      # No prefix — remoteKey is the full secret name.
+      # If you add a prefix, the final name is: prefix + remoteKey.
+---
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-to-aws
+spec:
+  secretStoreRefs:
+    - name: aws-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: app-secrets    # K8s Secret with DB_HOST, DB_USER, DB_PASS
+  dataTo:
+    - storeRef:
+        name: aws-store
+      remoteKey: my-app/db-config   # → AWS secret named exactly "my-app/db-config"
+      match:
+        regexp: "^DB_"
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          secretPushFormat: string    # store as SecretString (readable in console)
+```
+
+Result in AWS Secrets Manager:
+```text
+my-app/db-config → {"DB_HOST":"localhost","DB_USER":"admin","DB_PASS":"s3cr3t"}
+```
+
+!!! warning "Metadata requires the full PushSecretMetadata wrapper"
+    The `metadata` field is not a plain key-value map. It must be a valid
+    `PushSecretMetadata` object with `apiVersion`, `kind`, and `spec`. Putting
+    `secretPushFormat: string` directly under `metadata:` will cause a parse error.
+
+**With a store prefix:**
+
+```yaml
+# SecretStore has prefix: myapp/
+# dataTo remoteKey: db-config
+# → AWS secret name: myapp/db-config
+```
+
+### Azure Key Vault
+
+```yaml
+dataTo:
+  - storeRef:
+      name: azure-store
+    remoteKey: app-db-config    # Azure Key Vault secret name
+    match:
+      regexp: "^DB_"
+```
+
+### GCP Secret Manager
+
+```yaml
+dataTo:
+  - storeRef:
+      name: gcp-store
+    remoteKey: projects/my-project/secrets/app-db-config
+    match:
+      regexp: "^DB_"
+```
+
+### HashiCorp Vault
+
+```yaml
+dataTo:
+  - storeRef:
+      name: vault-store
+    remoteKey: secret/data/myapp/db    # Vault path (KV v2 style)
+    match:
+      regexp: "^DB_"
+```
+
+### GitHub Actions
+
+```yaml
+dataTo:
+  - storeRef:
+      name: github-store
+    # No remoteKey — each K8s key becomes its own Actions secret
+    match:
+      regexp: "^DEPLOY_"
+```
+
+Result: individual GitHub Actions secrets named `DEPLOY_TOKEN`, `DEPLOY_ENV`, etc.
+
+### Doppler
+
+```yaml
+dataTo:
+  - storeRef:
+      name: doppler-store
+    # No remoteKey — each K8s key becomes its own Doppler variable
+```
+
+## Filtering with `match`
+
+Use `match.regexp` to push only a subset of keys. When omitted, all keys are included.
+
+```yaml
+dataTo:
+  - storeRef:
+      name: aws-store
+    remoteKey: myapp/db-secrets
+    match:
+      regexp: "^DB_"      # only keys starting with DB_
+```
+
+```yaml
+dataTo:
+  - storeRef:
+      name: aws-store
+    remoteKey: myapp/all-secrets
+    # no match → all keys in the source Secret
+```
+
+## Key transformations with `rewrite`
+
+`rewrite` only applies in **per-key mode** (no `remoteKey`). It transforms the key name before it
+becomes the provider variable/secret name. Two rewrite types are available:
+
+### Regexp rewrite
+
+```yaml
+dataTo:
+  - storeRef:
+      name: github-store
+    match:
+      regexp: "^db-"
+    rewrite:
+      - regexp:
+          source: "^db-"
+          target: "DATABASE_"   # db-host → DATABASE_host
+```
+
+### Template rewrite
+
+{% raw %}
+```yaml
+dataTo:
+  - storeRef:
+      name: github-store
+    rewrite:
+      - transform:
+          template: "{{ .value | upper }}"   # db-host → DB-HOST
+```
+{% endraw %}
+
+### Chained rewrites
+
+Multiple rewrites are applied in order — each sees the output of the previous:
+
+```yaml
+dataTo:
+  - storeRef:
+      name: github-store
+    match:
+      regexp: "^prod-db-"
+    rewrite:
+      - regexp: {source: "^prod-", target: ""}        # prod-db-host → db-host
+      - regexp: {source: "^db-", target: "DATABASE_"} # db-host      → DATABASE_host
+```
+
+!!! tip "Rewrites are ignored in bundle mode"
+    When `remoteKey` is set, key names are not used as provider paths — only their values
+    appear in the JSON object. Rewrite entries are silently ignored in this case.
+
+## Multiple `dataTo` entries
+
+Split matched keys across different targets in the same PushSecret:
+
+```yaml
+# AWS: two separate secrets, each scoped to a category
+dataTo:
+  - storeRef:
+      name: aws-store
+    remoteKey: myapp/database
+    match:
+      regexp: "^DB_"
+  - storeRef:
+      name: aws-store
+    remoteKey: myapp/api
+    match:
+      regexp: "^API_"
+```
+
+```yaml
+# GitHub: separate env-var groups pushed to different stores
+dataTo:
+  - storeRef:
+      name: github-prod-store
+    match:
+      regexp: "^PROD_"
+  - storeRef:
+      name: github-staging-store
+    match:
+      regexp: "^STAGING_"
+```
+
+## Combining `dataTo` with explicit `data`
+
+Explicit `data` entries **always override** `dataTo` for the same source key. Use this to apply
+bulk defaults and then carve out exceptions:
+
+```yaml
+spec:
+  dataTo:
+    - storeRef:
+        name: aws-store
+      remoteKey: myapp/config       # all keys bundled here by default
+  data:
+    - match:
+        secretKey: MASTER_PASSWORD
+        remoteRef:
+          remoteKey: myapp/security/master-password   # this key gets its own secret
+```
+
+## Conversion strategy
+
+`conversionStrategy: ReverseUnicode` decodes Unicode-escaped key names before matching and
+pushing. Applied before `match` and `rewrite`:
+
+```yaml
+dataTo:
+  - storeRef:
+      name: aws-store
+    remoteKey: myapp/config
+    conversionStrategy: ReverseUnicode
+```
+
+## Error handling
+
+| Situation | Behavior |
+|---|---|
+| Invalid regexp in `match` | PushSecret enters error state; check `.status.conditions` |
+| Rewrite produces empty key | Reconciliation fails with the offending source key named |
+| Two entries produce the same remote key | Reconciliation fails listing all conflicting sources |
+| `match` matches no keys | Not an error; info log, PushSecret stays Ready |
+| `storeRef` not in `secretStoreRefs` | Validation error on apply |
+
+## Best practices
+
+1. **Always set `remoteKey` for named-secret providers** (AWS SM, Azure KV, GCP SM, Vault) — omitting it creates one secret per key, which is almost never what you want on these providers
+2. **Never set `remoteKey` for env-var providers** (GitHub Actions, Doppler) — the key name IS the variable name
+3. **Filter before you bundle** — use `match.regexp` to be explicit about which keys end up in a bundle; avoids accidentally including sensitive keys
+4. **Test patterns first** — inspect your source Secret's keys before writing patterns:
+   ```bash
+   kubectl get secret my-secret -o jsonpath='{.data}' | jq 'keys'
+   ```
+5. **Combine with `data` for exceptions** — use `dataTo` for the common case, explicit `data` entries for keys that need custom paths or properties
+6. **Monitor status** — check `kubectl get pushsecret <name> -o yaml` for sync errors
+
+## See Also
+
+- [PushSecret Guide](pushsecrets.md) - Basic PushSecret usage
+- [PushSecret API Reference](../api/pushsecret.md) - Complete API specification
+- [Templating Guide](templating.md) - Advanced template usage
+- [ExternalSecret dataFrom](datafrom-rewrite.md) - The mirror image: pulling secrets from providers

+ 17 - 0
docs/snippets/pushsecret-datato-basic.yaml

@@ -0,0 +1,17 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-basic
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Push all keys with original names
+  dataTo:
+    - storeRef:
+        name: secret-store

+ 30 - 0
docs/snippets/pushsecret-datato-chained.yaml

@@ -0,0 +1,30 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-chained
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Apply multiple transformations sequentially
+  dataTo:
+    - storeRef:
+        name: secret-store
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        # First: Remove "db-" prefix
+        - regexp:
+            source: "^db-"
+            target: ""
+        # Second: Add "prod/" prefix
+        - regexp:
+            source: "^"
+            target: "prod/"
+      # db-host -> host -> prod/host
+      # db-port -> port -> prod/port

+ 27 - 0
docs/snippets/pushsecret-datato-override.yaml

@@ -0,0 +1,27 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-override
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Push all keys with original names
+  dataTo:
+    - storeRef:
+        name: secret-store
+  # But override specific keys
+  data:
+    - match:
+        secretKey: db-host
+        remoteRef:
+          remoteKey: custom/database/hostname
+    - match:
+        secretKey: api-key
+        remoteRef:
+          remoteKey: custom/api/secret-key

+ 19 - 0
docs/snippets/pushsecret-datato-regex.yaml

@@ -0,0 +1,19 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-regex
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Only push keys matching the pattern
+  dataTo:
+    - storeRef:
+        name: secret-store
+      match:
+        regexp: "^db-.*"  # Match all keys starting with "db-"

+ 25 - 0
docs/snippets/pushsecret-datato-rewrite.yaml

@@ -0,0 +1,25 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-rewrite
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Push keys with transformation
+  dataTo:
+    - storeRef:
+        name: secret-store
+      match:
+        regexp: "^db-.*"
+      rewrite:
+        - regexp:
+            source: "^db-"
+            target: "myapp/database/"
+      # db-host -> myapp/database/host
+      # db-port -> myapp/database/port

+ 22 - 0
docs/snippets/pushsecret-datato-template.yaml

@@ -0,0 +1,22 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-datato-template
+  namespace: default
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: source-secret
+  # Use Go templates to transform keys
+  dataTo:
+    - storeRef:
+        name: secret-store
+      rewrite:
+        - transform:
+            template: "secrets/{{ .value | upper }}"
+      # username -> secrets/USERNAME
+      # password -> secrets/PASSWORD

+ 590 - 33
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -18,17 +18,22 @@ limitations under the License.
 package pushsecret
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
 	"maps"
+	"regexp"
+	"slices"
 	"strings"
+	"text/template"
 	"time"
 
 	"github.com/go-logr/logr"
 	v1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/rest"
@@ -48,6 +53,7 @@ import (
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 	"github.com/external-secrets/external-secrets/runtime/statemanager"
+	estemplate "github.com/external-secrets/external-secrets/runtime/template/v2"
 	"github.com/external-secrets/external-secrets/runtime/util/locks"
 
 	// Load registered generators.
@@ -64,6 +70,7 @@ const (
 	errConvert                 = "could not apply conversion strategy to keys: %v"
 	pushSecretFinalizer        = "pushsecret.externalsecrets.io/finalizer"
 	errCloudNotUpdateFinalizer = "could not update finalizers: %w"
+	bundleSourceKey            = "(bundle)"
 )
 
 // Reconciler is the controller for PushSecret resources.
@@ -79,6 +86,13 @@ type Reconciler struct {
 	ControllerClass string
 }
 
+// storeInfo holds the identifying attributes of a secret store for per-store processing.
+type storeInfo struct {
+	Name   string
+	Kind   string
+	Labels map[string]string
+}
+
 // SetupWithManager sets up the controller with the Manager.
 // It configures the controller to watch PushSecret resources and
 // manages indexing for efficient lookups based on secret stores and deletion policies.
@@ -207,10 +221,20 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{RequeueAfter: refreshInt}, nil
 	}
 
+	if err := validateDataToStoreRefs(ps.Spec.DataTo, ps.Spec.SecretStoreRefs); err != nil {
+		r.markAsFailed(err.Error(), &ps, nil)
+		return ctrl.Result{}, err
+	}
+
 	secrets, err := r.resolveSecrets(ctx, &ps)
 	if err != nil {
+		isSecretSelector := ps.Spec.Selector.Secret != nil && ps.Spec.Selector.Secret.Name != ""
+		if apierrors.IsNotFound(err) && isSecretSelector &&
+			ps.Spec.DeletionPolicy == esapi.PushSecretDeletionPolicyDelete &&
+			len(ps.Status.SyncedPushSecrets) > 0 {
+			return ctrl.Result{}, r.handleSourceSecretDeleted(ctx, &ps, mgr)
+		}
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
-
 		return ctrl.Result{}, err
 	}
 	secretStores, err := r.GetSecretStores(ctx, ps)
@@ -241,6 +265,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, nil
 	}
 
+	if err := validateDataToMatchesResolvedStores(ps.Spec.DataTo, secretStores); err != nil {
+		r.markAsFailed(err.Error(), &ps, nil)
+		return ctrl.Result{}, err
+	}
+
 	allSyncedSecrets := make(esapi.SyncedPushSecretsMap)
 	for _, secret := range secrets {
 		if err := r.applyTemplate(ctx, &ps, &secret); err != nil {
@@ -280,6 +309,23 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	return ctrl.Result{RequeueAfter: refreshInt}, nil
 }
 
+// handleSourceSecretDeleted cleans up provider secrets when source Secret is unavailable.
+func (r *Reconciler) handleSourceSecretDeleted(ctx context.Context, ps *esapi.PushSecret, mgr *secretstore.Manager) error {
+	log := r.Log.WithValues("pushsecret", client.ObjectKeyFromObject(ps))
+	log.Info("source secret unavailable, cleaning up provider secrets", "syncedSecrets", len(ps.Status.SyncedPushSecrets))
+
+	badState, err := r.DeleteSecretFromProviders(ctx, ps, esapi.SyncedPushSecretsMap{}, mgr)
+	if err != nil {
+		msg := fmt.Sprintf("failed to cleanup provider secrets: %v", err)
+		r.markAsFailed(msg, ps, badState)
+		return err
+	}
+
+	r.setSecrets(ps, esapi.SyncedPushSecretsMap{})
+	r.markAsSourceDeleted(ps)
+	return nil
+}
+
 func shouldRefresh(ps esapi.PushSecret) bool {
 	if ps.Status.SyncedResourceVersion != ctrlutil.GetResourceVersion(ps.ObjectMeta) {
 		return true
@@ -302,6 +348,13 @@ func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState es
 	r.recorder.Event(ps, v1.EventTypeWarning, esapi.ReasonErrored, msg)
 }
 
+func (r *Reconciler) markAsSourceDeleted(ps *esapi.PushSecret) {
+	msg := "source secret deleted; provider secrets cleaned up"
+	cond := NewPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonSourceDeleted, msg)
+	SetPushSecretCondition(ps, *cond)
+	r.recorder.Event(ps, v1.EventTypeNormal, esapi.ReasonSourceDeleted, msg)
+}
+
 func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSecretsMap, start time.Time) {
 	msg := "PushSecret synced successfully"
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
@@ -399,8 +452,10 @@ func (r *Reconciler) PushSecretToProviders(
 	mgr *secretstore.Manager,
 ) (esapi.SyncedPushSecretsMap, error) {
 	out := make(esapi.SyncedPushSecretsMap)
+	var err error
 	for ref, store := range stores {
-		out, err := r.handlePushSecretDataForStore(ctx, ps, secret, out, mgr, store.GetName(), ref.Kind)
+		si := storeInfo{Name: store.GetName(), Kind: ref.Kind, Labels: store.GetLabels()}
+		out, err = r.handlePushSecretDataForStore(ctx, ps, secret, out, mgr, si)
 		if err != nil {
 			return out, err
 		}
@@ -414,51 +469,109 @@ func (r *Reconciler) handlePushSecretDataForStore(
 	secret *v1.Secret,
 	out esapi.SyncedPushSecretsMap,
 	mgr *secretstore.Manager,
-	storeName, refKind string,
+	si storeInfo,
 ) (esapi.SyncedPushSecretsMap, error) {
-	storeKey := fmt.Sprintf("%v/%v", refKind, storeName)
+	storeKey := fmt.Sprintf("%v/%v", si.Kind, si.Name)
 	out[storeKey] = make(map[string]esapi.PushSecretData)
 	storeRef := esv1.SecretStoreRef{
-		Name: storeName,
-		Kind: refKind,
+		Name: si.Name,
+		Kind: si.Kind,
 	}
-	originalSecretData := secret.Data
 	secretClient, err := mgr.Get(ctx, storeRef, ps.GetNamespace(), nil)
 	if err != nil {
-		return out, fmt.Errorf("could not get secrets client for store %v: %w", storeName, err)
+		return out, fmt.Errorf("could not get secrets client for store %v: %w", si.Name, err)
 	}
-	for _, data := range ps.Spec.Data {
-		secretData, err := esutils.ReverseKeys(data.ConversionStrategy, originalSecretData)
-		if err != nil {
-			return nil, fmt.Errorf(errConvert, err)
-		}
-		secret.Data = secretData
-		key := data.GetSecretKey()
-		if !secretKeyExists(key, secret) {
-			return out, fmt.Errorf("secret key %v does not exist", key)
-		}
-		switch ps.Spec.UpdatePolicy {
-		case esapi.PushSecretUpdatePolicyIfNotExists:
-			exists, err := secretClient.SecretExists(ctx, data.Match.RemoteRef)
-			if err != nil {
-				return out, fmt.Errorf("could not verify if secret exists in store: %w", err)
-			} else if exists {
-				out[storeKey][statusRef(data)] = data
-				continue
-			}
-		case esapi.PushSecretUpdatePolicyReplace:
-		default:
+
+	storeSecret := secret.DeepCopy()
+
+	filteredDataTo, err := filterDataToForStore(ps.Spec.DataTo, si.Name, si.Kind, si.Labels)
+	if err != nil {
+		return out, fmt.Errorf("failed to filter dataTo: %w", err)
+	}
+
+	dataToEntries, bundleOverrides, err := r.expandDataTo(storeSecret, filteredDataTo)
+	if err != nil {
+		return out, fmt.Errorf("failed to expand dataTo: %w", err)
+	}
+
+	allData, err := mergeDataEntries(dataToEntries, ps.Spec.Data, storeSecret)
+	if err != nil {
+		return out, fmt.Errorf("failed to merge data entries: %w", err)
+	}
+
+	originalStoreSecretData := storeSecret.Data
+
+	for _, data := range allData {
+		params := pushEntryParams{
+			data:         data,
+			updatePolicy: ps.Spec.UpdatePolicy,
+			originalData: originalStoreSecretData,
+			dataOverride: bundleOverrides[statusRef(data)],
+			storeName:    si.Name,
 		}
-		if err := secretClient.PushSecret(ctx, secret, data); err != nil {
-			return out, fmt.Errorf(errSetSecretFailed, key, storeName, err)
+		if err := r.pushSecretEntry(ctx, secretClient, storeSecret, params); err != nil {
+			return out, err
 		}
 		out[storeKey][statusRef(data)] = data
 	}
 	return out, nil
 }
 
-func secretKeyExists(key string, secret *v1.Secret) bool {
-	_, ok := secret.Data[key]
+// pushEntryParams groups the parameters for pushSecretEntry to keep the
+// function signature within the recommended parameter count.
+type pushEntryParams struct {
+	data         esapi.PushSecretData
+	updatePolicy esapi.PushSecretUpdatePolicy
+	originalData map[string][]byte
+	dataOverride map[string][]byte
+	storeName    string
+}
+
+// pushSecretEntry converts, validates, and pushes a single data entry to the provider.
+// If the update policy is IfNotExists and the secret already exists, the push is skipped.
+// params.dataOverride, when non-nil, replaces params.originalData for the conversion step —
+// used by bundle entries (dataTo with remoteKey) to restrict the pushed payload to matched keys only.
+func (r *Reconciler) pushSecretEntry(
+	ctx context.Context,
+	secretClient esv1.SecretsClient,
+	storeSecret *v1.Secret,
+	params pushEntryParams,
+) error {
+	sourceData := params.originalData
+	if params.dataOverride != nil {
+		sourceData = params.dataOverride
+	}
+
+	secretData, err := esutils.ReverseKeys(params.data.ConversionStrategy, sourceData)
+	if err != nil {
+		return fmt.Errorf(errConvert, err)
+	}
+
+	key := params.data.GetSecretKey()
+	if !secretKeyExists(key, secretData) {
+		return fmt.Errorf("secret key %v does not exist", key)
+	}
+
+	if params.updatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
+		exists, err := secretClient.SecretExists(ctx, params.data.Match.RemoteRef)
+		if err != nil {
+			return fmt.Errorf("could not verify if secret exists in store: %w", err)
+		}
+		if exists {
+			return nil
+		}
+	}
+
+	localSecret := storeSecret.DeepCopy()
+	localSecret.Data = secretData
+	if err := secretClient.PushSecret(ctx, localSecret, params.data); err != nil {
+		return fmt.Errorf(errSetSecretFailed, key, params.storeName, err)
+	}
+	return nil
+}
+
+func secretKeyExists(key string, data map[string][]byte) bool {
+	_, ok := data[key]
 	return key == "" || ok
 }
 
@@ -711,3 +824,447 @@ func removeUnmanagedStores(ctx context.Context, namespace string, r *Reconciler,
 	}
 	return ss, nil
 }
+
+// matchKeys filters secret keys based on the provided match pattern.
+// If pattern is nil or empty, all keys are matched.
+func matchKeys(allKeys []string, match *esapi.PushSecretDataToMatch) ([]string, error) {
+	if match == nil || match.RegExp == "" {
+		return allKeys, nil
+	}
+
+	re, err := regexp.Compile(match.RegExp)
+	if err != nil {
+		return nil, fmt.Errorf("failed to compile regexp pattern %q: %w", match.RegExp, err)
+	}
+
+	matched := make([]string, 0)
+	for _, key := range allKeys {
+		if re.MatchString(key) {
+			matched = append(matched, key)
+		}
+	}
+
+	return matched, nil
+}
+
+// filterDataToForStore returns dataTo entries that target the given store.
+func filterDataToForStore(dataToList []esapi.PushSecretDataTo, storeName, storeKind string, storeLabels map[string]string) ([]esapi.PushSecretDataTo, error) {
+	filtered := make([]esapi.PushSecretDataTo, 0, len(dataToList))
+	for i, dataTo := range dataToList {
+		matches, err := dataToMatchesStore(dataTo, storeName, storeKind, storeLabels)
+		if err != nil {
+			return nil, fmt.Errorf("dataTo[%d]: %w", i, err)
+		}
+		if matches {
+			filtered = append(filtered, dataTo)
+		}
+	}
+	return filtered, nil
+}
+
+// dataToMatchesStore reports whether a single dataTo entry targets the given store.
+func dataToMatchesStore(dataTo esapi.PushSecretDataTo, storeName, storeKind string, storeLabels map[string]string) (bool, error) {
+	if dataTo.StoreRef == nil {
+		return false, fmt.Errorf("storeRef is required")
+	}
+	refKind := dataTo.StoreRef.Kind
+	if refKind == "" {
+		refKind = esv1.SecretStoreKind
+	}
+	if dataTo.StoreRef.Name != "" {
+		return dataTo.StoreRef.Name == storeName && refKind == storeKind, nil
+	}
+	if dataTo.StoreRef.LabelSelector == nil {
+		return false, nil
+	}
+	selector, err := metav1.LabelSelectorAsSelector(dataTo.StoreRef.LabelSelector)
+	if err != nil {
+		return false, fmt.Errorf("invalid labelSelector: %w", err)
+	}
+	return refKind == storeKind && selector.Matches(labels.Set(storeLabels)), nil
+}
+
+// expandDataTo expands dataTo entries into individual PushSecretData entries.
+//
+// Two modes are supported per dataTo entry:
+//
+// Per-key mode (default, no remoteKey set): each matched key becomes a separate entry
+// pushed independently. This enables individual key transformation, per-key status
+// tracking, granular deletion, and compatibility with all providers.
+//
+// Bundle mode (remoteKey set): all matched keys are bundled into a single provider
+// secret at the given remoteKey path as a JSON object. A single PushSecretData entry
+// with SecretKey="" is produced, and the bundleOverrides map carries the filtered
+// key set so only matched keys appear in the pushed JSON blob.
+//
+// Returns the expanded entries, a bundleOverrides map (remoteKey -> filtered data),
+// and any error.
+func (r *Reconciler) expandDataTo(secret *v1.Secret, dataToList []esapi.PushSecretDataTo) ([]esapi.PushSecretData, map[string]map[string][]byte, error) {
+	if len(dataToList) == 0 {
+		return nil, nil, nil
+	}
+
+	allData := make([]esapi.PushSecretData, 0)
+	bundleOverrides := make(map[string]map[string][]byte)
+	overallRemoteKeys := make(map[string]string)
+
+	for i, dataTo := range dataToList {
+		entries, keyMap, filteredData, err := r.expandSingleDataTo(secret, dataTo)
+		if err != nil {
+			return nil, nil, fmt.Errorf("dataTo[%d]: %w", i, err)
+		}
+		if len(entries) == 0 {
+			r.Log.Info("dataTo entry matched no keys", "index", i)
+			continue
+		}
+
+		if err := registerRemoteKeys(overallRemoteKeys, keyMap, i); err != nil {
+			return nil, nil, err
+		}
+
+		recordBundleOverrides(bundleOverrides, entries, filteredData)
+
+		allData = append(allData, entries...)
+		r.Log.Info("expanded dataTo entry", "index", i, "matchedKeys", len(entries), "created", len(keyMap))
+	}
+
+	return allData, bundleOverrides, nil
+}
+
+// registerRemoteKeys checks for duplicate remote keys across dataTo entries and
+// records new mappings. Returns an error if a duplicate is found.
+func registerRemoteKeys(seen, keyMap map[string]string, index int) error {
+	for sourceKey, remoteKey := range keyMap {
+		if existingSource, exists := seen[remoteKey]; exists {
+			return fmt.Errorf("dataTo[%d]: duplicate remote key %q from source key %q (conflicts with %s)", index, remoteKey, sourceKey, existingSource)
+		}
+		seen[remoteKey] = fmt.Sprintf("dataTo[%d]:%s", index, sourceKey)
+	}
+	return nil
+}
+
+// recordBundleOverrides associates filtered data with bundle entries so only
+// matched keys appear in the pushed JSON blob.
+func recordBundleOverrides(overrides map[string]map[string][]byte, entries []esapi.PushSecretData, filteredData map[string][]byte) {
+	if filteredData == nil {
+		return
+	}
+	for _, entry := range entries {
+		overrides[statusRef(entry)] = filteredData
+	}
+}
+
+// expandSingleDataTo processes a single dataTo entry: converts keys, matches them
+// against the pattern, applies rewrites, validates remote keys, and builds the
+// resulting PushSecretData entries along with the source-to-remote key mapping.
+//
+// Bundle mode: when dataTo.RemoteKey is set, all matched keys are bundled into a
+// single PushSecretData entry with SecretKey="" targeting dataTo.RemoteKey. The
+// third return value carries the filtered (matched+converted) key data so that
+// only matched keys appear in the JSON blob pushed to the provider.
+//
+// Per-key mode: when dataTo.RemoteKey is empty, one PushSecretData entry is
+// produced per matched key. The third return value is nil.
+func (r *Reconciler) expandSingleDataTo(secret *v1.Secret, dataTo esapi.PushSecretDataTo) ([]esapi.PushSecretData, map[string]string, map[string][]byte, error) {
+	if dataTo.RemoteKey != "" && len(dataTo.Rewrite) > 0 {
+		return nil, nil, nil, fmt.Errorf("remoteKey and rewrite are mutually exclusive: rewrite is only supported in per-key mode (without remoteKey)")
+	}
+
+	convertedData, err := esutils.ReverseKeys(dataTo.ConversionStrategy, secret.Data)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("conversion failed: %w", err)
+	}
+
+	// Map converted keys back to the original K8s secret keys. The resulting
+	// PushSecretData entries store the original key so that
+	// resolveSourceKeyConflicts can compare dataTo entries against explicit
+	// data entries in the same key space. ConversionStrategy is set to None on
+	// expanded entries because the conversion was already applied during
+	// matching and rewriting; handlePushSecretDataForStore will look up the
+	// original key directly in the unconverted secret data.
+	convertedToOriginal := make(map[string]string, len(secret.Data))
+	for origKey := range secret.Data {
+		convKey := esutils.ReverseKey(dataTo.ConversionStrategy, origKey)
+		convertedToOriginal[convKey] = origKey
+	}
+
+	allKeys := make([]string, 0, len(convertedData))
+	for key := range convertedData {
+		allKeys = append(allKeys, key)
+	}
+	slices.Sort(allKeys)
+
+	matchedKeys, err := matchKeys(allKeys, dataTo.Match)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("match failed: %w", err)
+	}
+	if len(matchedKeys) == 0 {
+		return nil, nil, nil, nil
+	}
+
+	matchedData := make(map[string][]byte, len(matchedKeys))
+	for _, key := range matchedKeys {
+		matchedData[key] = convertedData[key]
+	}
+
+	if dataTo.RemoteKey != "" {
+		keyMap := map[string]string{bundleSourceKey: dataTo.RemoteKey}
+		entry := esapi.PushSecretData{
+			Match: esapi.PushSecretMatch{
+				SecretKey: "",
+				RemoteRef: esapi.PushSecretRemoteRef{
+					RemoteKey: dataTo.RemoteKey,
+				},
+			},
+			Metadata:           dataTo.Metadata,
+			ConversionStrategy: esapi.PushSecretConversionNone,
+		}
+		return []esapi.PushSecretData{entry}, keyMap, matchedData, nil
+	}
+
+	keyMap, err := rewriteWithKeyMapping(dataTo.Rewrite, matchedData)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("rewrite failed: %w", err)
+	}
+
+	for sourceKey, remoteKey := range keyMap {
+		if remoteKey == "" {
+			return nil, nil, nil, fmt.Errorf("empty remote key produced for source key %q", sourceKey)
+		}
+	}
+
+	sortedKeys := slices.Sorted(maps.Keys(keyMap))
+	entries := make([]esapi.PushSecretData, 0, len(keyMap))
+	for _, convertedKey := range sortedKeys {
+		entries = append(entries, esapi.PushSecretData{
+			Match: esapi.PushSecretMatch{
+				SecretKey: convertedToOriginal[convertedKey],
+				RemoteRef: esapi.PushSecretRemoteRef{
+					RemoteKey: keyMap[convertedKey],
+				},
+			},
+			Metadata:           dataTo.Metadata,
+			ConversionStrategy: esapi.PushSecretConversionNone,
+		})
+	}
+
+	return entries, keyMap, nil, nil
+}
+
+// validateDataToStoreRefs checks that each dataTo entry has a valid storeRef.
+func validateDataToStoreRefs(dataToList []esapi.PushSecretDataTo, storeRefs []esapi.PushSecretStoreRef) error {
+	for i, d := range dataToList {
+		if d.StoreRef == nil {
+			return fmt.Errorf("dataTo[%d]: storeRef is required", i)
+		}
+		if d.StoreRef.Name == "" && d.StoreRef.LabelSelector == nil {
+			return fmt.Errorf("dataTo[%d]: storeRef must have name or labelSelector", i)
+		}
+		if d.StoreRef.Name != "" && !storeRefExistsInList(d.StoreRef, storeRefs) {
+			return fmt.Errorf("dataTo[%d]: storeRef %q not found in secretStoreRefs", i, d.StoreRef.Name)
+		}
+	}
+	return nil
+}
+
+// storeRefExistsInList checks if a named ref matches any named entry in storeRefs.
+func storeRefExistsInList(ref *esapi.PushSecretStoreRef, storeRefs []esapi.PushSecretStoreRef) bool {
+	refKind := ref.Kind
+	if refKind == "" {
+		refKind = esv1.SecretStoreKind
+	}
+	for _, sr := range storeRefs {
+		if sr.Name == "" {
+			continue
+		}
+		srKind := sr.Kind
+		if srKind == "" {
+			srKind = esv1.SecretStoreKind
+		}
+		if srKind == refKind && sr.Name == ref.Name {
+			return true
+		}
+	}
+	return false
+}
+
+// validateDataToMatchesResolvedStores checks that every dataTo entry with a
+// labelSelector actually matches at least one resolved store. Without this,
+// a misconfigured labelSelector silently becomes a no-op.
+func validateDataToMatchesResolvedStores(dataToList []esapi.PushSecretDataTo, stores map[esapi.PushSecretStoreRef]esv1.GenericStore) error {
+	for i, dataTo := range dataToList {
+		if dataTo.StoreRef == nil || dataTo.StoreRef.LabelSelector == nil {
+			continue
+		}
+		if dataTo.StoreRef.Name != "" {
+			continue
+		}
+
+		selector, err := metav1.LabelSelectorAsSelector(dataTo.StoreRef.LabelSelector)
+		if err != nil {
+			return fmt.Errorf("dataTo[%d]: invalid labelSelector: %w", i, err)
+		}
+
+		refKind := dataTo.StoreRef.Kind
+		if refKind == "" {
+			refKind = esv1.SecretStoreKind
+		}
+
+		if !anyStoreMatchesSelector(refKind, selector, stores) {
+			return fmt.Errorf("dataTo[%d]: labelSelector does not match any store in secretStoreRefs", i)
+		}
+	}
+	return nil
+}
+
+// anyStoreMatchesSelector returns true if at least one resolved store matches
+// the given kind and label selector.
+func anyStoreMatchesSelector(kind string, selector labels.Selector, stores map[esapi.PushSecretStoreRef]esv1.GenericStore) bool {
+	for ref, store := range stores {
+		if ref.Kind == kind && selector.Matches(labels.Set(store.GetLabels())) {
+			return true
+		}
+	}
+	return false
+}
+
+// rewriteWithKeyMapping applies rewrites and returns originalKey -> rewrittenKey mapping.
+func rewriteWithKeyMapping(rewrites []esapi.PushSecretRewrite, data map[string][]byte) (map[string]string, error) {
+	keyMap := make(map[string]string, len(data))
+	for k := range data {
+		keyMap[k] = k
+	}
+
+	for i, op := range rewrites {
+		applyFn, err := compileRewrite(op)
+		if err != nil {
+			return nil, fmt.Errorf("rewrite[%d]: %w", i, err)
+		}
+		newKeyMap := make(map[string]string, len(keyMap))
+		for origKey, currentKey := range keyMap {
+			newKey, err := applyFn(currentKey)
+			if err != nil {
+				return nil, fmt.Errorf("rewrite[%d] on key %q: %w", i, currentKey, err)
+			}
+			newKeyMap[origKey] = newKey
+		}
+		keyMap = newKeyMap
+	}
+
+	return keyMap, nil
+}
+
+// compileRewrite pre-compiles a rewrite operation (regexp or template) and
+// returns a function that applies it to a key. This avoids re-compiling the
+// same regexp or re-parsing the same template for every key.
+func compileRewrite(op esapi.PushSecretRewrite) (func(string) (string, error), error) {
+	switch {
+	case op.Regexp != nil:
+		re, err := regexp.Compile(op.Regexp.Source)
+		if err != nil {
+			return nil, fmt.Errorf("invalid regexp %q: %w", op.Regexp.Source, err)
+		}
+		target := op.Regexp.Target
+		return func(key string) (string, error) {
+			return re.ReplaceAllString(key, target), nil
+		}, nil
+	case op.Transform != nil:
+		tmpl, err := template.New("t").Funcs(estemplate.FuncMap()).Parse(op.Transform.Template)
+		if err != nil {
+			return nil, fmt.Errorf("invalid template: %w", err)
+		}
+		return func(key string) (string, error) {
+			var buf bytes.Buffer
+			if err := tmpl.Execute(&buf, map[string]string{"value": key}); err != nil {
+				return "", fmt.Errorf("template exec: %w", err)
+			}
+			return buf.String(), nil
+		}, nil
+	default:
+		return func(key string) (string, error) { return key, nil }, nil
+	}
+}
+
+// resolveSourceKeyConflicts merges dataTo and explicit data entries.
+// When both reference the same source secret key, explicit data wins.
+// Comparison is done using the original (raw) K8s secret key. DataTo entries
+// already store the original key; explicit data entries may store a converted
+// key when ConversionStrategy is set, so we normalize them via the secret.
+func resolveSourceKeyConflicts(dataToEntries, explicitData []esapi.PushSecretData, secret *v1.Secret) []esapi.PushSecretData {
+	explicitOriginalKeys := make(map[string]struct{}, len(explicitData))
+	for _, data := range explicitData {
+		origKey := resolveOriginalKey(data, secret)
+		explicitOriginalKeys[origKey] = struct{}{}
+	}
+
+	result := make([]esapi.PushSecretData, 0, len(dataToEntries)+len(explicitData))
+	for _, data := range dataToEntries {
+		if _, exists := explicitOriginalKeys[data.GetSecretKey()]; !exists {
+			result = append(result, data)
+		}
+	}
+
+	return append(result, explicitData...)
+}
+
+// resolveOriginalKey returns the raw K8s secret key for a PushSecretData entry.
+// If the entry uses a ConversionStrategy, SecretKey is the converted (decoded)
+// form, so we find the original key by converting each raw key and matching.
+// If no conversion is active, SecretKey is already the original key.
+func resolveOriginalKey(data esapi.PushSecretData, secret *v1.Secret) string {
+	key := data.GetSecretKey()
+	if data.ConversionStrategy == "" || data.ConversionStrategy == esapi.PushSecretConversionNone {
+		return key
+	}
+	for origKey := range secret.Data {
+		if esutils.ReverseKey(data.ConversionStrategy, origKey) == key {
+			return origKey
+		}
+	}
+	return key
+}
+
+// validateRemoteKeyUniqueness ensures no two entries push to the same remote location.
+// The remote location is defined by (remoteKey, property) tuple.
+func validateRemoteKeyUniqueness(entries []esapi.PushSecretData) error {
+	type remoteLocation struct {
+		remoteKey string
+		property  string
+	}
+
+	seen := make(map[remoteLocation]string) // location -> source key (for error message)
+
+	for _, data := range entries {
+		loc := remoteLocation{
+			remoteKey: data.GetRemoteKey(),
+			property:  data.GetProperty(),
+		}
+		sourceKey := data.GetSecretKey()
+
+		if existingSource, exists := seen[loc]; exists {
+			if loc.property != "" {
+				return fmt.Errorf(
+					"duplicate remote key %q with property %q: source keys %q and %q both map to the same destination",
+					loc.remoteKey, loc.property, existingSource, sourceKey)
+			}
+			return fmt.Errorf(
+				"duplicate remote key %q: source keys %q and %q both map to the same destination",
+				loc.remoteKey, existingSource, sourceKey)
+		}
+		seen[loc] = sourceKey
+	}
+
+	return nil
+}
+
+// mergeDataEntries combines dataTo and explicit data entries.
+// It resolves source key conflicts (explicit wins) and validates no duplicate remote destinations.
+func mergeDataEntries(dataToEntries, explicitData []esapi.PushSecretData, secret *v1.Secret) ([]esapi.PushSecretData, error) {
+	merged := resolveSourceKeyConflicts(dataToEntries, explicitData, secret)
+
+	if err := validateRemoteKeyUniqueness(merged); err != nil {
+		return nil, err
+	}
+
+	return merged, nil
+}

File diff suppressed because it is too large
+ 1536 - 151
pkg/controllers/pushsecret/pushsecret_controller_test.go


+ 2 - 1
providers/v1/vault/provider_test.go

@@ -20,6 +20,7 @@ import (
 	"errors"
 	"fmt"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	vault "github.com/hashicorp/vault/api"
@@ -304,7 +305,7 @@ MIIFkTCCA3mgAwIBAgIUBEUg3m/WqAsWHG4Q/II3IePFfuowDQYJKoZIhvcNAQELBQAwWDELMAkGA1UE
 				}),
 			},
 			want: want{
-				err: errors.New("time: invalid duration \"not-an-interval\""),
+				err: func() error { _, err := time.ParseDuration("not-an-interval"); return err }(),
 			},
 		},
 		"ValidRetrySettings": {

+ 5 - 0
runtime/esutils/utils.go

@@ -351,6 +351,11 @@ func ReverseKeys(strategy esv1alpha1.PushSecretConversionStrategy, in map[string
 	return out, nil
 }
 
+// ReverseKey applies the conversion strategy to a single key name.
+func ReverseKey(strategy esv1alpha1.PushSecretConversionStrategy, key string) string {
+	return reverse(strategy, key)
+}
+
 func reverse(strategy esv1alpha1.PushSecretConversionStrategy, str string) string {
 	switch strategy {
 	case esv1alpha1.PushSecretConversionReverseUnicode:

+ 3 - 1
runtime/testing/fake/fake.go

@@ -26,6 +26,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/esutils"
 )
 
 var _ esv1.Provider = &Client{}
@@ -95,8 +96,9 @@ func (v *Client) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind)
 
 func (v *Client) PushSecret(_ context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
 	v.mu.Lock()
+	value, _ := esutils.ExtractSecretData(data, secret)
 	v.pushSecretData[data.GetRemoteKey()] = SetSecretCallArgs{
-		Value:     secret.Data[data.GetSecretKey()],
+		Value:     value,
 		RemoteRef: data,
 	}
 	fn := v.SetSecretFn

+ 21 - 0
tests/__snapshot__/pushsecret-v1alpha1.yaml

@@ -10,6 +10,27 @@ spec:
         remoteKey: string
       secretKey: string
     metadata: 
+  dataTo:
+  - conversionStrategy: "None"
+    match:
+      regexp: string
+    metadata: 
+    remoteKey: string
+    rewrite:
+    - regexp:
+        source: string
+        target: string
+      transform:
+        template: string
+    storeRef:
+      kind: "SecretStore"
+      labelSelector:
+        matchExpressions:
+        - key: string
+          operator: string
+          values: [] # minItems 0 of type string
+        matchLabels: {}
+      name: string
   deletionPolicy: "None"
   refreshInterval: "1h0m0s"
   secretStoreRefs: