Skip to content

OCPBUGS-31384: UPSTREAM: <carry>: allow type mutation for specific secrets #1924

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pkg/apis/core/validation/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6407,7 +6407,12 @@ func ValidateSecret(secret *core.Secret) field.ErrorList {
func ValidateSecretUpdate(newSecret, oldSecret *core.Secret) field.ErrorList {
allErrs := ValidateObjectMetaUpdate(&newSecret.ObjectMeta, &oldSecret.ObjectMeta, field.NewPath("metadata"))

allErrs = append(allErrs, ValidateImmutableField(newSecret.Type, oldSecret.Type, field.NewPath("type"))...)
// TODO: this is a short term fix, we can drop this patch once we

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we change the commit title to UPSTREAM: <drop>: allow type mutation for specific secrets ?

// migrate all of the affected secret objects to to intended type,
// see https://issues.redhat.com/browse/API-1800
if !openShiftValidateSecretUpdateIsTypeMutationAllowed(newSecret, oldSecret) {
allErrs = append(allErrs, ValidateImmutableField(newSecret.Type, oldSecret.Type, field.NewPath("type"))...)
}
if oldSecret.Immutable != nil && *oldSecret.Immutable {
if newSecret.Immutable == nil || !*newSecret.Immutable {
allErrs = append(allErrs, field.Forbidden(field.NewPath("immutable"), "field is immutable when `immutable` is set"))
Expand Down
53 changes: 53 additions & 0 deletions pkg/apis/core/validation/validation_patch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
Copyright 2024 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package validation

import (
"k8s.io/kubernetes/pkg/apis/core"
)

var (
// we have multiple controllers reconciling the same secret,
// resulting in unexpected outcomes such as the generation of new key pairs.
// our goal is to prevent the generation of new key pairs by disallowing
// deletions and permitting only updates, which appear to be 'safe'.
//
// thus we make an exception for the secrets in the following namespaces, during update
// we allow the secret type to mutate from:
// "SecretTypeTLS" -> "kubernetes.io/tls"
// some of our operators were accidentally creating secrets of type
// "SecretTypeTLS", and this patch enables us to move these secrets
// objects to the intended type in a ratcheting manner.
//
// we can drop this patch when we migrate all of the affected secret
// objects to to intended type: https://issues.redhat.com/browse/API-1800
whitelist = map[string]struct{}{
"openshift-kube-apiserver-operator": {},
"openshift-kube-apiserver": {},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"openshift-kube-controller-manager-operator": {},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
)

func openShiftValidateSecretUpdateIsTypeMutationAllowed(newSecret, oldSecret *core.Secret) bool {
// we allow "SecretTypeTLS" -> "kubernetes.io/tls" only
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain that some of our operators were accidentally creating secrets with type SecretTypeTLS and this patch is to allow ratcheting them to the intended type.

if oldSecret.Type == "SecretTypeTLS" && newSecret.Type == core.SecretTypeTLS {
if _, ok := whitelist[oldSecret.Namespace]; ok {
return true
}
}
return false
}
134 changes: 134 additions & 0 deletions pkg/apis/core/validation/validation_patch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
Copyright 2024 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package validation

import (
"testing"

"github.com/google/go-cmp/cmp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/kubernetes/pkg/apis/core"
)

func TestOpenShiftValidateSecretUpdate(t *testing.T) {
newSecretFn := func(ns, name string, secretType core.SecretType) *core.Secret {
return &core.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
ResourceVersion: "1",
},
Type: secretType,
Data: map[string][]byte{
"tls.key": []byte("foo"),
"tls.crt": []byte("bar"),
},
}
}
invalidTypeErrFn := func(secretType core.SecretType) field.ErrorList {
return field.ErrorList{
field.Invalid(field.NewPath("type"), secretType, "field is immutable"),
}
}
tlsKeyRequiredErrFn := func() field.ErrorList {
return field.ErrorList{
field.Required(field.NewPath("data").Key(core.TLSCertKey), ""),
field.Required(field.NewPath("data").Key(core.TLSPrivateKeyKey), ""),
}
}

t.Run("verify whitelist", func(t *testing.T) {
for key := range whitelist {
ns, name := key, "foo"

// exercise a valid type mutation: "SecretTypeTLS" -> "kubernetes.io/tls"
oldSecret, newSecret := newSecretFn(ns, name, "SecretTypeTLS"), newSecretFn(ns, name, core.SecretTypeTLS)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the reverse direction too.

if errs := ValidateSecretUpdate(newSecret, oldSecret); len(errs) > 0 {
t.Errorf("unexpected error: %v", errs)
}

// the reverse should not be allowed
errExpected := invalidTypeErrFn("SecretTypeTLS")
oldSecret, newSecret = newSecretFn(ns, name, core.SecretTypeTLS), newSecretFn(ns, name, "SecretTypeTLS")
if errGot := ValidateSecretUpdate(newSecret, oldSecret); !cmp.Equal(errExpected, errGot) {
t.Errorf("expected error: %v, diff: %s", errExpected, cmp.Diff(errExpected, errGot))
}

// no type change, no validation failure expected
oldSecret, newSecret = newSecretFn(ns, name, core.SecretTypeTLS), newSecretFn(ns, name, core.SecretTypeTLS)
if errs := ValidateSecretUpdate(newSecret, oldSecret); len(errs) > 0 {
t.Errorf("unexpected error: %v", errs)
}

// exercise an invalid type mutation, we expect validation failure
errExpected = invalidTypeErrFn(core.SecretTypeOpaque)
oldSecret, newSecret = newSecretFn(ns, name, "SecretTypeTLS"), newSecretFn(ns, name, core.SecretTypeOpaque)
if errGot := ValidateSecretUpdate(newSecret, oldSecret); !cmp.Equal(errExpected, errGot) {
t.Errorf("expected error: %v, diff: %s", errExpected, cmp.Diff(errExpected, errGot))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't doubt that it works but I would like to see a test that shows the kubernetes.io/tls key validation is still enforced on these updates.


// verify that kbernetes.io/tls validation ar enforced
errExpected = tlsKeyRequiredErrFn()
oldSecret, newSecret = newSecretFn(ns, name, "SecretTypeTLS"), newSecretFn(ns, name, core.SecretTypeTLS)
newSecret.Data = nil
if errGot := ValidateSecretUpdate(newSecret, oldSecret); !cmp.Equal(errExpected, errGot) {
t.Errorf("expected error: %v, diff: %s", errExpected, cmp.Diff(errExpected, errGot))
}
}
})

// we must not break secrets that are not in the whitelist
tests := []struct {
name string
oldSecret *core.Secret
newSecret *core.Secret
errExpected field.ErrorList
}{
{
name: "secret is not whitelisted, valid type transition, update not allowed",
oldSecret: newSecretFn("foo", "bar", "SecretTypeTLS"),
newSecret: newSecretFn("foo", "bar", core.SecretTypeTLS),
errExpected: invalidTypeErrFn(core.SecretTypeTLS),
},
{
name: "secret is not whitelisted, invalid type transition, update not allowed",
oldSecret: newSecretFn("foo", "bar", "SecretTypeTLS"),
newSecret: newSecretFn("foo", "bar", core.SecretTypeOpaque),
errExpected: invalidTypeErrFn(core.SecretTypeOpaque),
},
{
name: "secret is not whitelisted, no type transition, update allowed",
oldSecret: newSecretFn("foo", "bar", core.SecretTypeTLS),
newSecret: newSecretFn("foo", "bar", core.SecretTypeTLS),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, ok := whitelist[test.oldSecret.Namespace]; ok {
t.Errorf("misconfigured test: secret is in whitelist: %s", test.oldSecret.Namespace)
return
}

errGot := ValidateSecretUpdate(test.newSecret, test.oldSecret)
if !cmp.Equal(test.errExpected, errGot) {
t.Errorf("expected error: %v, diff: %s", test.errExpected, cmp.Diff(test.errExpected, errGot))
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
Copyright 2024 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package apiserver

import (
"context"
"testing"

corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
"k8s.io/kubernetes/test/integration/framework"
)

// the list was copied from pkg/apis/core/validation/validation_patch.go
var whitelistedSecretNamespaces = map[string]struct{}{
"openshift-kube-apiserver-operator": {},
"openshift-kube-apiserver": {},
"openshift-kube-controller-manager-operator": {},
}

func TestOpenShiftValidateWhiteListedSecretTypeMutationUpdateAllowed(t *testing.T) {
ctx := context.Background()
server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd())
if err != nil {
t.Fatal(err)
}
t.Cleanup(server.TearDownFn)
client, err := kubernetes.NewForConfig(server.ClientConfig)
if err != nil {
t.Fatal(err)
}

for whiteListedSecretNamespace := range whitelistedSecretNamespaces {
_, err := client.CoreV1().Namespaces().Get(ctx, whiteListedSecretNamespace, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
testNamespace := framework.CreateNamespaceOrDie(client, whiteListedSecretNamespace, t)
t.Cleanup(func() { framework.DeleteNamespaceOrDie(client, testNamespace, t) })
} else if err != nil {
t.Fatal(err)
}

secret := constructSecretWithOldType(whiteListedSecretNamespace, "foo")
createdSecret, err := client.CoreV1().Secrets(whiteListedSecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
t.Errorf("failed to create secret, err = %v", err)
}

createdSecret.Type = corev1.SecretTypeTLS
updatedSecret, err := client.CoreV1().Secrets(whiteListedSecretNamespace).Update(ctx, createdSecret, metav1.UpdateOptions{})
if err != nil {
t.Errorf("failed to update the type of the secret, err = %v", err)
}
if updatedSecret.Type != corev1.SecretTypeTLS {
t.Errorf("unexpected type of the secret = %v, expected = %v", updatedSecret.Type, corev1.SecretTypeTLS)
}

// "kubernetes.io/tls" -> "SecretTypeTLS" is not allowed
toUpdateSecret := updatedSecret
toUpdateSecret.Type = "SecretTypeTLS"
_, err = client.CoreV1().Secrets(whiteListedSecretNamespace).Update(ctx, toUpdateSecret, metav1.UpdateOptions{})
if !apierrors.IsInvalid(err) {
t.Errorf("unexpected error returned: %v", err)
}
}
}

func TestNotWhiteListedSecretTypeMutationUpdateDisallowed(t *testing.T) {
ctx := context.Background()
server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd())
if err != nil {
t.Fatal(err)
}
t.Cleanup(server.TearDownFn)
client, err := kubernetes.NewForConfig(server.ClientConfig)
if err != nil {
t.Fatal(err)
}

testNamespace := framework.CreateNamespaceOrDie(client, "secret-type-update-disallowed", t)
t.Cleanup(func() { framework.DeleteNamespaceOrDie(client, testNamespace, t) })

secret := constructSecretWithOldType(testNamespace.Name, "foo")
createdSecret, err := client.CoreV1().Secrets(testNamespace.Name).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
t.Errorf("failed to create secret, err = %v", err)
}

createdSecret.Type = corev1.SecretTypeTLS
_, err = client.CoreV1().Secrets(testNamespace.Name).Update(ctx, createdSecret, metav1.UpdateOptions{})
if !apierrors.IsInvalid(err) {
t.Errorf("unexpected error returned: %v", err)
}
}

func constructSecretWithOldType(ns, name string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: name,
},
Type: "SecretTypeTLS",
Data: map[string][]byte{"tls.crt": {}, "tls.key": {}},
}
}