Repository: elastic/cloud-on-k8s

Issue:
Copied secrets from StackConfigPolicy are not cleaned up when removed from spec
### Description

When a StackConfigPolicy (SCP) references a secret via `secretMounts`, ECK copies that secret to the Elasticsearch namespace with the appropriate naming convention. However, when the StackConfigPolicy is updated to remove the secret reference, the copied secret remains and is not cleaned up.

### Steps to Reproduce

1. Create an Elasticsearch with the label `env: test`

1. Create the secret to be referenced inside the secretSources
```bash
kubectl create secret generic test --from-literal=test=test -n elastic-system -o yaml --save-config --dry-run=client | kubectl apply -f -
```

2. Create a StackConfigPolicy with a `secretSources` reference to a secret:
```yaml
apiVersion: stackconfigpolicy.k8s.elastic.co/v1alpha1
kind: StackConfigPolicy
metadata:
  name: test-stack-config-policy
  namespace: elastic-system
spec:
  resourceSelector:
    matchLabels:
      env: test
  elasticsearch:
    clusterSettings:
      indices.recovery.max_bytes_per_sec: "100mb"
    secretMounts:
      - secretName: test
        mountPath: /mnt/secret/test
```

2. Apply the SCP and verify that ECK copies the secret to the namespace of Elasticsearch

3. Update the StackConfigPolicy and remove `secretMounts`.

4. Observe that the previously copied secret still exists in the target namespace

### Expected Behaviour

When a secret reference is removed from the StackConfigPolicy's `secretMounts`, ECK should:
- Delete the corresponding copied secret
- Clean up any secrets that are no longer referenced by any StackConfigPolicy

### Actual Behaviour

The copied secret remains in the target namespace indefinitely, even after the reference is removed from the StackConfigPolicy.

https://github.com/user-attachments/assets/2ca25342-c342-4cb8-809b-8d9a92493b9f

### Environment

- **ECK Version**: v3.2.0


Hints from issue comments:

Solution patch:
diff --git a/NOTICE.txt b/NOTICE.txt
index bea858990..6563a50cf 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -2946,11 +2946,11 @@ SOFTWARE.
 
 --------------------------------------------------------------------------------
 Module  : go.elastic.co/apm/module/apmelasticsearch/v2
-Version : v2.7.1
-Time    : 2025-05-26T15:07:42Z
+Version : v2.7.2
+Time    : 2025-11-28T10:07:18Z
 Licence : Apache-2.0
 
-Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmelasticsearch/v2@v2.7.1/LICENSE:
+Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmelasticsearch/v2@v2.7.2/LICENSE:
 
                                  Apache License
                            Version 2.0, January 2004
@@ -3157,11 +3157,11 @@ Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmelasti
 
 --------------------------------------------------------------------------------
 Module  : go.elastic.co/apm/module/apmhttp/v2
-Version : v2.7.1
-Time    : 2025-05-26T15:07:42Z
+Version : v2.7.2
+Time    : 2025-11-28T10:07:18Z
 Licence : Apache-2.0
 
-Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmhttp/v2@v2.7.1/LICENSE:
+Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmhttp/v2@v2.7.2/LICENSE:
 
                                  Apache License
                            Version 2.0, January 2004
@@ -3368,11 +3368,11 @@ Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmhttp/v
 
 --------------------------------------------------------------------------------
 Module  : go.elastic.co/apm/module/apmzap/v2
-Version : v2.7.1
-Time    : 2025-05-26T15:07:42Z
+Version : v2.7.2
+Time    : 2025-11-28T10:07:18Z
 Licence : Apache-2.0
 
-Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmzap/v2@v2.7.1/LICENSE:
+Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmzap/v2@v2.7.2/LICENSE:
 
                                  Apache License
                            Version 2.0, January 2004
@@ -3579,11 +3579,11 @@ Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/module/apmzap/v2
 
 --------------------------------------------------------------------------------
 Module  : go.elastic.co/apm/v2
-Version : v2.7.1
-Time    : 2025-05-26T15:07:42Z
+Version : v2.7.2
+Time    : 2025-11-28T10:07:18Z
 Licence : Apache-2.0
 
-Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/v2@v2.7.1/LICENSE:
+Contents of probable licence file $GOMODCACHE/go.elastic.co/apm/v2@v2.7.2/LICENSE:
 
                                  Apache License
                            Version 2.0, January 2004
diff --git a/build/Dockerfile b/build/Dockerfile
index e1ab00c97..0f5d9e24b 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -1,5 +1,5 @@
 # Build the operator binary
-FROM docker.elastic.co/wolfi/go:1.25.4-r0@sha256:0447bc63c01761fd14bd45576d6a502bd1030aea313ee69fe78370e8dcccef73 as builder
+FROM docker.elastic.co/wolfi/go:1.25.5-r0@sha256:0399daf4c6cc8ae155ba8b5dbe17af20361fd08fda6a84c500f9db24ce74cbcc as builder
 
 ARG VERSION
 ARG SHA1
diff --git a/build/Dockerfile-ubi b/build/Dockerfile-ubi
index cc37fd91d..5d98b9dfc 100644
--- a/build/Dockerfile-ubi
+++ b/build/Dockerfile-ubi
@@ -1,5 +1,5 @@
 # Build the operator binary
-FROM docker.elastic.co/wolfi/go:1.25.4-r0@sha256:0447bc63c01761fd14bd45576d6a502bd1030aea313ee69fe78370e8dcccef73 as builder
+FROM docker.elastic.co/wolfi/go:1.25.5-r0@sha256:0399daf4c6cc8ae155ba8b5dbe17af20361fd08fda6a84c500f9db24ce74cbcc as builder
 
 ARG VERSION
 ARG SHA1
diff --git a/docs/reference/third-party-dependencies/main.md b/docs/reference/third-party-dependencies/main.md
index 00c1f6ad7..81a8b9b66 100644
--- a/docs/reference/third-party-dependencies/main.md
+++ b/docs/reference/third-party-dependencies/main.md
@@ -42,10 +42,10 @@ This page lists the third-party dependencies used to build {{eck}} from the main
 | [github.com/spf13/pflag](https://github.com/spf13/pflag) | v1.0.10 | BSD-3-Clause |
 | [github.com/spf13/viper](https://github.com/spf13/viper) | v1.21.0 | MIT |
 | [github.com/stretchr/testify](https://github.com/stretchr/testify) | v1.11.1 | MIT |
-| [go.elastic.co/apm/module/apmelasticsearch/v2](https://go.elastic.co/apm/module/apmelasticsearch/v2) | v2.7.1 | Apache-2.0 |
-| [go.elastic.co/apm/module/apmhttp/v2](https://go.elastic.co/apm/module/apmhttp/v2) | v2.7.1 | Apache-2.0 |
-| [go.elastic.co/apm/module/apmzap/v2](https://go.elastic.co/apm/module/apmzap/v2) | v2.7.1 | Apache-2.0 |
-| [go.elastic.co/apm/v2](https://go.elastic.co/apm/v2) | v2.7.1 | Apache-2.0 |
+| [go.elastic.co/apm/module/apmelasticsearch/v2](https://go.elastic.co/apm/module/apmelasticsearch/v2) | v2.7.2 | Apache-2.0 |
+| [go.elastic.co/apm/module/apmhttp/v2](https://go.elastic.co/apm/module/apmhttp/v2) | v2.7.2 | Apache-2.0 |
+| [go.elastic.co/apm/module/apmzap/v2](https://go.elastic.co/apm/module/apmzap/v2) | v2.7.2 | Apache-2.0 |
+| [go.elastic.co/apm/v2](https://go.elastic.co/apm/v2) | v2.7.2 | Apache-2.0 |
 | [go.uber.org/automaxprocs](https://go.uber.org/automaxprocs) | v1.6.0 | MIT |
 | [go.uber.org/zap](https://go.uber.org/zap) | v1.27.0 | MIT |
 | [golang.org/x/crypto](https://golang.org/x/crypto) | v0.45.0 | BSD-3-Clause |
diff --git a/go.mod b/go.mod
index 99714c5f2..5ad3987f5 100644
--- a/go.mod
+++ b/go.mod
@@ -29,10 +29,10 @@ require (
 	github.com/spf13/pflag v1.0.10
 	github.com/spf13/viper v1.21.0
 	github.com/stretchr/testify v1.11.1
-	go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.1
-	go.elastic.co/apm/module/apmhttp/v2 v2.7.1
-	go.elastic.co/apm/module/apmzap/v2 v2.7.1
-	go.elastic.co/apm/v2 v2.7.1
+	go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.2
+	go.elastic.co/apm/module/apmhttp/v2 v2.7.2
+	go.elastic.co/apm/module/apmzap/v2 v2.7.2
+	go.elastic.co/apm/v2 v2.7.2
 	go.uber.org/automaxprocs v1.6.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.45.0
diff --git a/go.sum b/go.sum
index 811d93e76..fc0e3a613 100644
--- a/go.sum
+++ b/go.sum
@@ -281,14 +281,14 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.1 h1:nA7I325lIxf6hlVN1SdoOuVJG3gadWlejWngfadGUP8=
-go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.1/go.mod h1:+Xna0uioc2zNzfQzal40RfZ5PPLd2jRBsv49TGHmF8Y=
-go.elastic.co/apm/module/apmhttp/v2 v2.7.1 h1:1uPHesdm9nKytQ/N0bPmlS7F69oXvkzW+IlvzQuDUs8=
-go.elastic.co/apm/module/apmhttp/v2 v2.7.1/go.mod h1:DlBnNivf+eArsEI1QtUx7fygo/JDbdMIcU9+i/Wid1U=
-go.elastic.co/apm/module/apmzap/v2 v2.7.1 h1:s0s06RqHGauRNPk0N6xTmXaETXpNcTe6qJq3HyK2d8Q=
-go.elastic.co/apm/module/apmzap/v2 v2.7.1/go.mod h1:ZVYX+sXq++VNZDecaOsLWbCqsmIlOnmdUnaAUnGU9mQ=
-go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
-go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
+go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.2 h1:oy9PA89RuFAbGO4Mhvv0lzwUuHgX1HJUXYZfuR7WJn8=
+go.elastic.co/apm/module/apmelasticsearch/v2 v2.7.2/go.mod h1:Nlv96Nq6AvRhG10NHyWCgU1zWGF9cS8G7l1RXgr3WIs=
+go.elastic.co/apm/module/apmhttp/v2 v2.7.2 h1:grLycchDH4B6aGRkZjIV/sweAivJDl8IcP+nCorktm8=
+go.elastic.co/apm/module/apmhttp/v2 v2.7.2/go.mod h1:N9CJn3x7cyFnZ54WKxgm/t76drcsmSpu6aU8zGwP4zQ=
+go.elastic.co/apm/module/apmzap/v2 v2.7.2 h1:ZSD60tqZHliyeh1swJl3sn0IVotnjzJ/GfEsfyq1mo0=
+go.elastic.co/apm/module/apmzap/v2 v2.7.2/go.mod h1:8W4zAMwEdz3cOfmO2i26QWBPjRwWmOakhusG4a44M4w=
+go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA=
+go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4=
 go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
 go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
diff --git a/hack/manifest-gen/Dockerfile b/hack/manifest-gen/Dockerfile
index 1a4fd86a9..81701d709 100644
--- a/hack/manifest-gen/Dockerfile
+++ b/hack/manifest-gen/Dockerfile
@@ -1,4 +1,4 @@
-FROM docker.elastic.co/wolfi/go:1.25.4-r0@sha256:0447bc63c01761fd14bd45576d6a502bd1030aea313ee69fe78370e8dcccef73 as builder
+FROM docker.elastic.co/wolfi/go:1.25.5-r0@sha256:0399daf4c6cc8ae155ba8b5dbe17af20361fd08fda6a84c500f9db24ce74cbcc as builder
 ADD . /manifest-gen
 WORKDIR /manifest-gen
 ENV GO111MODULE=on CGO_ENABLED=0 GOOS=linux 
diff --git a/hack/manifest-gen/go.mod b/hack/manifest-gen/go.mod
index 5226339a6..d28a07c07 100644
--- a/hack/manifest-gen/go.mod
+++ b/hack/manifest-gen/go.mod
@@ -2,7 +2,7 @@ module github.com/elastic/cloud-on-k8s/v3/hack/manifest-gen
 
 go 1.24.0
 
-toolchain go1.25.4
+toolchain go1.25.5
 
 require (
 	github.com/spf13/cobra v1.10.1
diff --git a/hack/release-notes/go.mod b/hack/release-notes/go.mod
index 51ddce13d..11a720c75 100644
--- a/hack/release-notes/go.mod
+++ b/hack/release-notes/go.mod
@@ -2,7 +2,7 @@ module github.com/elastic/cloud-on-k8s/v3/hack/release-notes
 
 go 1.24.0
 
-toolchain go1.25.4
+toolchain go1.25.5
 
 require (
 	github.com/stretchr/testify v1.11.1
diff --git a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go
index f190cb637..f5c1bc96c 100644
--- a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go
+++ b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"encoding/json"
 
+	"k8s.io/apimachinery/pkg/util/sets"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/metadata"
@@ -24,6 +25,7 @@ import (
 	"github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s"
 
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 )
@@ -77,7 +79,11 @@ func newElasticsearchConfigSecret(policy policyv1alpha1.StackConfigPolicy, es es
 }
 
 // reconcileSecretMounts creates the secrets in SecretMounts to the respective Elasticsearch namespace where they should be mounted to.
+// It also removes any previously mounted secrets that are no longer in policy.Spec.Elasticsearch.SecretMounts.
 func reconcileSecretMounts(ctx context.Context, c k8s.Client, es esv1.Elasticsearch, policy *policyv1alpha1.StackConfigPolicy, meta metadata.Metadata) error {
+	// Track which secret mounts are defined in the policy (by their target secret name in ES namespace)
+	secretMountsInPolicy := make(sets.Set[string], len(policy.Spec.Elasticsearch.SecretMounts))
+
 	for _, secretMount := range policy.Spec.Elasticsearch.SecretMounts {
 		additionalSecret := corev1.Secret{}
 		namespacedName := types.NamespacedName{
@@ -114,6 +120,54 @@ func reconcileSecretMounts(ctx context.Context, c k8s.Client, es esv1.Elasticsea
 		if _, err := reconciler.ReconcileSecret(ctx, c, expected, nil); err != nil {
 			return err
 		}
+
+		secretMountsInPolicy.Insert(secretName)
+	}
+
+	// Clean up secret mounts that are no longer in the policy
+	return cleanupOrphanedSecretMounts(ctx, c, es, k8s.ExtractNamespacedName(policy), secretMountsInPolicy)
+}
+
+// cleanupOrphanedSecretMounts removes copied secrets that are owned by the given policy and do not exist in the given
+// secretMountsInPolicy set. This handles the case where a policy is updated in-place and SecretMounts list is affected.
+// See https://github.com/elastic/cloud-on-k8s/issues/8921
+func cleanupOrphanedSecretMounts(ctx context.Context, c k8s.Client, es esv1.Elasticsearch, policyNsn types.NamespacedName, secretMountsInPolicy sets.Set[string]) error {
+	// List all secrets in the ES namespace that are soft-owned by this policy.
+	// The label selector below ensures we only find secrets that: (1) are soft-owned
+	// by this specific policy, (2) are marked for deletion when the policy is deleted,
+	// and (3) belong to this specific ES cluster.
+	var secrets corev1.SecretList
+	matchLabels := client.MatchingLabels{
+		reconciler.SoftOwnerKindLabel:                   policyv1alpha1.Kind,
+		reconciler.SoftOwnerNameLabel:                   policyNsn.Name,
+		reconciler.SoftOwnerNamespaceLabel:              policyNsn.Namespace,
+		commonlabels.StackConfigPolicyOnDeleteLabelName: commonlabels.OrphanSecretDeleteOnPolicyDelete,
+		eslabel.ClusterNameLabelName:                    es.Name,
+	}
+
+	if err := c.List(ctx, &secrets, client.InNamespace(es.Namespace), matchLabels); err != nil {
+		return err
+	}
+
+	for i := range secrets.Items {
+		secret := &secrets.Items[i]
+
+		// Skip secrets that do not have commonannotation.SourceSecretAnnotationName which identifies the ones that
+		// were reconciled from a secret mount in the owner StackConfigPolicy. See reconcileSecretMounts func
+		if secret.Annotations[commonannotation.SourceSecretAnnotationName] == "" {
+			continue
+		}
+
+		// Check if this secret is in the expected set (still in SecretMounts)
+		if secretMountsInPolicy.Has(secret.Name) {
+			continue
+		}
+
+		// This secret is owned by the policy but no longer in SecretMounts - delete it
+		// Since these are single-owner secrets (filtered by labels), we can delete directly
+		if err := c.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
+			return err
+		}
 	}
 	return nil
 }



Test patch:
diff --git a/pkg/controller/stackconfigpolicy/controller_test.go b/pkg/controller/stackconfigpolicy/controller_test.go
index 0ebbfbcc9..c8804fbc1 100644
--- a/pkg/controller/stackconfigpolicy/controller_test.go
+++ b/pkg/controller/stackconfigpolicy/controller_test.go
@@ -709,6 +709,170 @@ func TestReconcileStackConfigPolicy_Reconcile(t *testing.T) {
 			wantErr:          false,
 			wantRequeueAfter: true,
 		},
+		{
+			name: "Secret mount is removed when policy is updated to remove it from SecretMounts",
+			args: args{
+				client: func() k8s.Client {
+					// Simulates a policy that previously had 2 SecretMounts (test-secret-mount, another-secret-mount)
+					// but was updated to only have 1 (test-secret-mount). The copied secrets from the previous
+					// reconciliation still exist, and we expect the cleanup logic to remove the orphaned one.
+					policyWithOneMount := policyFixture.DeepCopy()
+					policyWithOneMount.Spec.Elasticsearch.SecretMounts = []policyv1alpha1.SecretMount{
+						{
+							SecretName: "test-secret-mount",
+							MountPath:  "/usr/test",
+						},
+					}
+
+					// Source secrets in policy namespace (these don't need owner labels, they're just data sources)
+					sourceSecret1 := &corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-secret-mount",
+							Namespace: "ns",
+						},
+						Data: map[string][]byte{
+							"idfile.txt": []byte("test id file"),
+						},
+					}
+					sourceSecret2 := &corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "another-secret-mount",
+							Namespace: "ns",
+						},
+						Data: map[string][]byte{
+							"another-file.txt": []byte("another test file"),
+						},
+					}
+
+					// Copied secrets in ES namespace (created by previous reconciliation)
+					// These have the source secret annotation - should be eligible for cleanup
+					copiedSecret1 := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "test-secret-mount"), "ns", "test-policy", "ns", "delete")
+					copiedSecret1.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+					copiedSecret1.Annotations = map[string]string{
+						"policy.k8s.elastic.co/source-secret-name": "test-secret-mount",
+					}
+					copiedSecret2 := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "another-secret-mount"), "ns", "test-policy", "ns", "delete")
+					copiedSecret2.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+					copiedSecret2.Annotations = map[string]string{
+						"policy.k8s.elastic.co/source-secret-name": "another-secret-mount",
+					}
+
+					// Create a third secret owned by the policy but WITHOUT the source secret annotation
+					// This should NOT be deleted even though it's not in SecretMounts
+					secretWithoutAnnotation := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "test-es-other-secret"), "ns", "test-policy", "ns", "delete")
+					secretWithoutAnnotation.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+
+					return k8s.NewFakeClient(policyWithOneMount, &esFixture, &secretFixture, sourceSecret1, sourceSecret2, copiedSecret1, copiedSecret2, secretWithoutAnnotation, esPodFixture)
+				}(),
+				licenseChecker:   &license.MockLicenseChecker{EnterpriseEnabled: true},
+				esClientProvider: fakeClientProvider(clusterStateFileSettingsFixture(42, nil), nil),
+			},
+			post: func(r ReconcileStackConfigPolicy, recorder record.FakeRecorder) {
+				// Verify the first secret exists (reconciled because still in SecretMounts)
+				var copiedSecret1 corev1.Secret
+				err := r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "test-secret-mount"),
+					Namespace: "ns",
+				}, &copiedSecret1)
+				assert.NoError(t, err, "first copied secret should exist (still in SecretMounts)")
+
+				// Verify the second secret was deleted (removed from SecretMounts and has source annotation)
+				var copiedSecret2 corev1.Secret
+				err = r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "another-secret-mount"),
+					Namespace: "ns",
+				}, &copiedSecret2)
+				assert.True(t, apierrors.IsNotFound(err), "second copied secret should be deleted (removed from SecretMounts)")
+
+				// Verify the secret without annotation was NOT deleted (lacks source secret annotation)
+				var secretWithoutAnnotation corev1.Secret
+				err = r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "test-es-other-secret"),
+					Namespace: "ns",
+				}, &secretWithoutAnnotation)
+				assert.NoError(t, err, "secret without source annotation should NOT be deleted")
+			},
+			wantErr: false,
+		},
+		{
+			name: "All secret mounts removed from policy - all copied secrets should be deleted",
+			args: args{
+				client: func() k8s.Client {
+					// Simulates a policy that previously had SecretMounts but was updated to have none.
+					// All previously copied secrets with source annotations should be cleaned up.
+					policyWithNoMounts := policyFixture.DeepCopy()
+					policyWithNoMounts.Spec.Elasticsearch.SecretMounts = []policyv1alpha1.SecretMount{}
+
+					// Source secrets in policy namespace (left over from before, not currently used)
+					sourceSecret1 := &corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-secret-mount",
+							Namespace: "ns",
+						},
+						Data: map[string][]byte{
+							"idfile.txt": []byte("test id file"),
+						},
+					}
+					sourceSecret2 := &corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "another-secret-mount",
+							Namespace: "ns",
+						},
+						Data: map[string][]byte{
+							"another-file.txt": []byte("another test file"),
+						},
+					}
+
+					// Copied secrets in ES namespace (created by previous reconciliation)
+					// Both have the source secret annotation and should be cleaned up
+					copiedSecret1 := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "test-secret-mount"), "ns", "test-policy", "ns", "delete")
+					copiedSecret1.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+					copiedSecret1.Annotations = map[string]string{
+						"policy.k8s.elastic.co/source-secret-name": "test-secret-mount",
+					}
+					copiedSecret2 := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "another-secret-mount"), "ns", "test-policy", "ns", "delete")
+					copiedSecret2.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+					copiedSecret2.Annotations = map[string]string{
+						"policy.k8s.elastic.co/source-secret-name": "another-secret-mount",
+					}
+
+					// Create a secret owned by the policy but WITHOUT the source secret annotation
+					// This should NOT be deleted (not a SecretMount-managed secret)
+					secretWithoutAnnotation := getSecretMountSecret(t, esv1.StackConfigAdditionalSecretName("test-es", "test-es-other-secret"), "ns", "test-policy", "ns", "delete")
+					secretWithoutAnnotation.Labels["elasticsearch.k8s.elastic.co/cluster-name"] = "test-es"
+
+					return k8s.NewFakeClient(policyWithNoMounts, &esFixture, &secretFixture, sourceSecret1, sourceSecret2, copiedSecret1, copiedSecret2, secretWithoutAnnotation, esPodFixture)
+				}(),
+				licenseChecker:   &license.MockLicenseChecker{EnterpriseEnabled: true},
+				esClientProvider: fakeClientProvider(clusterStateFileSettingsFixture(42, nil), nil),
+			},
+			post: func(r ReconcileStackConfigPolicy, recorder record.FakeRecorder) {
+				// Verify both copied secrets with source annotations were deleted
+				var copiedSecret1 corev1.Secret
+				err := r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "test-secret-mount"),
+					Namespace: "ns",
+				}, &copiedSecret1)
+				assert.True(t, apierrors.IsNotFound(err), "first copied secret should be deleted (all SecretMounts removed)")
+
+				var copiedSecret2 corev1.Secret
+				err = r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "another-secret-mount"),
+					Namespace: "ns",
+				}, &copiedSecret2)
+				assert.True(t, apierrors.IsNotFound(err), "second copied secret should be deleted (all SecretMounts removed)")
+
+				// Verify the secret without annotation was NOT deleted
+				var secretWithoutAnnotation corev1.Secret
+				err = r.Client.Get(context.Background(), types.NamespacedName{
+					Name:      esv1.StackConfigAdditionalSecretName("test-es", "test-es-other-secret"),
+					Namespace: "ns",
+				}, &secretWithoutAnnotation)
+				assert.NoError(t, err, "secret without source annotation should NOT be deleted")
+			},
+			wantErr:          false,
+			wantRequeueAfter: true,
+		},
 	}
 
 	for _, tt := range tests {