Repository: bitnami-labs/sealed-secrets (language: go)

Issue:
Cannot create immutable secrets
Kubernetes 1.18+ (stable in 1.21) offers immutable secrets via the `immutable` attribute. See https://kubernetes.io/docs/concepts/configuration/secret/#secret-immutable.

However, this attribute is ignored by sealed-secrets.

The first issue is that `kubeseal` strips the `immutable` attribute from the generated SealedSecret resource:
```sh
kubeseal <<EOF
apiVersion: v1
kind: Secret
metadata:
  name: test
immutable: true
data:
  foo: deadbeef
EOF
```

That generates the following SealedSecret (without any reference to `immutable`):
```json
{
  "kind": "SealedSecret",
  "apiVersion": "bitnami.com/v1alpha1",
  "metadata": {
    "name": "test",
    "namespace": "default",
    "creationTimestamp": null
  },
  "spec": {
    "template": {
      "metadata": {
        "name": "test",
        "namespace": "default",
        "creationTimestamp": null
      }
    },
    "encryptedData": {
      "foo": "..."
    }
  }
}
```

But it's not just an issue with `kubeseal`. The sealed-secrets controller itself doesn't seem to support the `immutable` attribute. If we add `spect.template.immutable: true` to the above manifest, the generated secret still doesn't have `immutable` set.


Discussion / hints:
This Issue has been automatically marked as "stale" because it has not had recent activity (for 15 days). It will be closed if no further activity occurs. Thanks for the feedback.
+bumb
is this still an issue? I think in use cases like ArgoCD where you want the unsealed secrets that should not be modified this might be a valid solution. I will test this out today as to how it works.
any progress on this issue?
It would be great to have immutable secrets created when unsealing the one to avoid getting secrets edited manually.

Fix patch:
diff --git a/README.md b/README.md
index d8a7db1aea..8d1ffdd2b5 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,7 @@ In particular, the annotations and labels of a `SealedSecret` resource are not t
 
 To capture this distinction, the `SealedSecret` object has a `template` section which encodes all the fields you want the controller to put in the unsealed `Secret`.
 
-This includes metadata such as labels or annotations, but also things like the `type` of the secret.
+This includes metadata such as labels or annotations, but also things like `type` and `immutable` fields of the secret.
 
 ```yaml
 apiVersion: bitnami.com/v1alpha1
@@ -131,6 +131,7 @@ spec:
     .dockerconfigjson: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq.....
   template:
     type: kubernetes.io/dockerconfigjson
+    immutable: true
     # this is an example of labels and annotations that will be added to the output secret
     metadata:
       labels:
@@ -158,6 +159,7 @@ metadata:
     name: mysecret
     uid: 5caff6a0-c9ac-11e9-881e-42010aac003e
 type: kubernetes.io/dockerconfigjson
+immutable: true
 data:
   .dockerconfigjson: ewogICJjcmVk...
 ```
diff --git a/helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml b/helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml
index f37b5a7354..44b6cf4f64 100644
--- a/helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml
+++ b/helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml
@@ -91,6 +91,10 @@ spec:
                     description: Used to facilitate programmatic handling of secret
                       data.
                     type: string
+                  immutable:
+                    description: 'Immutable, if set to true, ensures that data stored in the Secret cannot be updated (only object metadata can be modified).
+                      If not set to true, the field can be modified at any time. Defaulted to nil.'
+                    type: boolean
                 type: object
             required:
             - encryptedData
diff --git a/pkg/apis/sealedsecrets/v1alpha1/sealedsecret_expansion.go b/pkg/apis/sealedsecrets/v1alpha1/sealedsecret_expansion.go
index 6c1b7459d7..ad5f2bf593 100644
--- a/pkg/apis/sealedsecrets/v1alpha1/sealedsecret_expansion.go
+++ b/pkg/apis/sealedsecrets/v1alpha1/sealedsecret_expansion.go
@@ -208,7 +208,8 @@ func NewSealedSecret(codecs runtimeserializer.CodecFactory, pubKey *rsa.PublicKe
 		Spec: SealedSecretSpec{
 			Template: SecretTemplateSpec{
 				// ObjectMeta copied below
-				Type: secret.Type,
+				Type:      secret.Type,
+				Immutable: secret.Immutable,
 			},
 			EncryptedData: map[string]string{},
 		},
@@ -266,6 +267,7 @@ func (s *SealedSecret) Unseal(codecs runtimeserializer.CodecFactory, privKeys ma
 	if s.Spec.Data == nil {
 		s.Spec.Template.ObjectMeta.DeepCopyInto(&secret.ObjectMeta)
 		secret.Type = s.Spec.Template.Type
+		secret.Immutable = s.Spec.Template.Immutable
 
 		secret.Data = map[string][]byte{}
 		data := map[string]string{}
diff --git a/pkg/apis/sealedsecrets/v1alpha1/types.go b/pkg/apis/sealedsecrets/v1alpha1/types.go
index 9375498ff7..3b2ba7eb24 100644
--- a/pkg/apis/sealedsecrets/v1alpha1/types.go
+++ b/pkg/apis/sealedsecrets/v1alpha1/types.go
@@ -51,6 +51,13 @@ type SecretTemplateSpec struct {
 	// +optional
 	Type apiv1.SecretType `json:"type,omitempty" protobuf:"bytes,3,opt,name=type,casttype=SecretType"`
 
+	// Immutable, if set to true, ensures that data stored in the Secret cannot
+	// be updated (only object metadata can be modified).
+	// If not set to true, the field can be modified at any time.
+	// Defaulted to nil.
+	// +optional
+	Immutable *bool `json:"immutable,omitempty" protobuf:"varint,5,opt,name=immutable"`
+
 	// Keys that should be templated using decrypted data.
 	// +optional
 	// +nullable
diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go
index 1224cc4e89..3af5947148 100644
--- a/pkg/controller/controller.go
+++ b/pkg/controller/controller.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"reflect"
+	"strings"
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
@@ -261,6 +262,11 @@ func (c *Controller) processNextItem(ctx context.Context) bool {
 	if err == nil {
 		// No error, reset the ratelimit counters
 		c.queue.Forget(key)
+	} else if isImmutableError(err) {
+		// Do not retry updating immutable fields of an immutable secret
+		log.Errorf(formatImmutableError(key.(string)))
+		c.queue.Forget(key)
+		utilruntime.HandleError(err)
 	} else if c.queue.NumRequeues(key) < maxRetries {
 		log.Errorf("Error updating %s, will retry: %v", key, err)
 		c.queue.AddRateLimited(key)
@@ -377,7 +383,12 @@ func (c *Controller) unseal(ctx context.Context, key string) (unsealErr error) {
 	if !apiequality.Semantic.DeepEqual(origSecret, secret) {
 		_, err = c.sclient.Secrets(ssecret.GetObjectMeta().GetNamespace()).Update(ctx, secret, metav1.UpdateOptions{})
 		if err != nil {
-			c.recorder.Event(ssecret, corev1.EventTypeWarning, ErrUpdateFailed, err.Error())
+			var message = err.Error()
+			if isImmutableError(err) {
+				message = formatImmutableError(key)
+			}
+
+			c.recorder.Event(ssecret, corev1.EventTypeWarning, ErrUpdateFailed, message)
 			unsealErrorsTotal.WithLabelValues("update", ssecret.GetNamespace()).Inc()
 			return err
 		}
@@ -468,6 +479,14 @@ func isAnnotatedToBePatched(secret *corev1.Secret) bool {
 	return secret.Annotations[ssv1alpha1.SealedSecretPatchAnnotation] == "true"
 }
 
+func isImmutableError(err error) bool {
+	return strings.HasSuffix(err.Error(), "field is immutable when `immutable` is set")
+}
+
+func formatImmutableError(key string) string {
+	return fmt.Sprintf("Error updating %s: the target Secret is immutable. Once a Secret is marked as immutable, it is not possible to revert this change nor to mutate the contents of the data field. You can only delete and recreate the Secret.", key)
+}
+
 // AttemptUnseal tries to unseal a secret.
 func (c *Controller) AttemptUnseal(content []byte) (bool, error) {
 	if err := multidocyaml.EnsureNotMultiDoc(content); err != nil {
diff --git a/schema-v1alpha1.yaml b/schema-v1alpha1.yaml
index 283f950fed..913f300946 100644
--- a/schema-v1alpha1.yaml
+++ b/schema-v1alpha1.yaml
@@ -55,6 +55,9 @@ openAPIV3Schema:
             type:
               description: Used to facilitate programmatic handling of secret data.
               type: string
+            immutable:
+              description: 'Immutable, if set to true, ensures that data stored in the Secret cannot be updated (only object metadata can be modified). If not set to true, the field can be modified at any time. Defaulted to nil.'
+              type: boolean
           type: object
       required:
         - encryptedData


Test patch:
diff --git a/integration/controller_test.go b/integration/controller_test.go
index 108752b174..94a51d5891 100644
--- a/integration/controller_test.go
+++ b/integration/controller_test.go
@@ -73,6 +73,10 @@ func getSecretType(s *v1.Secret) v1.SecretType {
 	return s.Type
 }
 
+func getSecretImmutable(s *v1.Secret) bool {
+	return *s.Immutable
+}
+
 func fetchKeys(ctx context.Context, c corev1.SecretsGetter) (map[string]*rsa.PrivateKey, []*x509.Certificate, error) {
 	list, err := c.Secrets(*controllerNs).List(ctx, metav1.ListOptions{
 		LabelSelector: keySelector,
@@ -121,6 +125,16 @@ func containEventWithReason(matcher types.GomegaMatcher) types.GomegaMatcher {
 	)
 }
 
+func containEventWithMessage(matcher types.GomegaMatcher) types.GomegaMatcher {
+	return WithTransform(
+		func(l *v1.EventList) []v1.Event { return l.Items },
+		ContainElement(WithTransform(
+			func(e v1.Event) string { return e.Message },
+			matcher,
+		)),
+	)
+}
+
 var _ = Describe("create", func() {
 	var c corev1.CoreV1Interface
 	var ssc ssclient.Interface
@@ -565,6 +579,7 @@ var _ = Describe("create", func() {
 			delete(ss.Spec.EncryptedData, "foo")
 			ss.Spec.Template.Type = "kubernetes.io/dockerconfigjson"
 		})
+
 		It("should produce expected Secret", func() {
 			expected := map[string][]byte{
 				".dockerconfigjson": []byte("{\"auths\": {\"https://index.docker.io/v1/\": {\"auth\": \"c3R...zE2\"}}}"),
@@ -588,6 +603,76 @@ var _ = Describe("create", func() {
 		})
 	})
 
+	Describe("Immutable Secret", func() {
+		BeforeEach(func() {
+			ss.Spec.Template.Immutable = new(bool)
+			*ss.Spec.Template.Immutable = true
+		})
+
+		It("should produce expected Secret", func() {
+			Eventually(func() (*v1.Secret, error) {
+				return c.Secrets(ns).Get(ctx, secretName, metav1.GetOptions{})
+			}, Timeout, PollingInterval).Should(WithTransform(getSecretImmutable, Equal(true)))
+			Eventually(func() (*ssv1alpha1.SealedSecret, error) {
+				return ssc.BitnamiV1alpha1().SealedSecrets(ns).Get(context.Background(), secretName, metav1.GetOptions{})
+			}, Timeout, PollingInterval).ShouldNot(WithTransform(getStatus, BeNil()))
+			Eventually(func() (*v1.EventList, error) {
+				return c.Events(ns).Search(scheme.Scheme, ss)
+			}, Timeout, PollingInterval).Should(
+				containEventWithReason(Equal("Unsealed")),
+			)
+		})
+	})
+
+	Describe("Immutable Secret Error", func() {
+		BeforeEach(func() {
+			ss.Spec.Template.Immutable = new(bool)
+			*ss.Spec.Template.Immutable = true
+		})
+
+		JustBeforeEach(func() {
+			var err error
+
+			Eventually(func() (*ssv1alpha1.SealedSecret, error) {
+				return ssc.BitnamiV1alpha1().SealedSecrets(ss.Namespace).Get(context.Background(), secretName, metav1.GetOptions{})
+			}, Timeout, PollingInterval).ShouldNot(WithTransform(getStatus, BeNil()))
+
+			ss, err = ssc.BitnamiV1alpha1().SealedSecrets(ss.Namespace).Get(context.Background(), secretName, metav1.GetOptions{})
+			Expect(err).NotTo(HaveOccurred())
+
+			resVer := ss.ResourceVersion
+
+			// update
+			s.Data["foo"] = []byte("baz")
+			ss, err = ssv1alpha1.NewSealedSecret(scheme.Codecs, pubKey, s)
+			Expect(err).NotTo(HaveOccurred())
+			ss.ResourceVersion = resVer
+
+			fmt.Fprintf(GinkgoWriter, "Updating to SealedSecret: %#v\n", ss)
+			ss, err = ssc.BitnamiV1alpha1().SealedSecrets(ss.Namespace).Update(context.Background(), ss, metav1.UpdateOptions{})
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("should record update failure as an event", func() {
+			Eventually(func() (*ssv1alpha1.SealedSecret, error) {
+				return ssc.BitnamiV1alpha1().SealedSecrets(ns).Get(context.Background(), secretName, metav1.GetOptions{})
+			}, Timeout, PollingInterval).ShouldNot(WithTransform(getStatus, BeNil()))
+			Eventually(func() (*ssv1alpha1.SealedSecret, error) {
+				return ssc.BitnamiV1alpha1().SealedSecrets(ns).Get(context.Background(), secretName, metav1.GetOptions{})
+			}, Timeout, PollingInterval).Should(WithTransform(getObservedGeneration, Equal(int64(2))))
+			Eventually(func() (*v1.EventList, error) {
+				return c.Events(ns).Search(scheme.Scheme, ss)
+			}, Timeout, PollingInterval).Should(
+				containEventWithReason(Equal("ErrUpdateFailed")),
+			)
+			Eventually(func() (*v1.EventList, error) {
+				return c.Events(ns).Search(scheme.Scheme, ss)
+			}, Timeout, PollingInterval).Should(
+				containEventWithMessage(ContainSubstring("the target Secret is immutable")),
+			)
+		})
+	})
+
 	Describe("Different name/namespace", func() {
 		Context("With wrong name", func() {
 			const secretName2 = "not-testsecret"