diff --git a/cmd/kube-apiserver/app/aggregator.go b/cmd/kube-apiserver/app/aggregator.go index 943012a4d9e..397c151e66a 100644 --- a/cmd/kube-apiserver/app/aggregator.go +++ b/cmd/kube-apiserver/app/aggregator.go @@ -261,6 +261,7 @@ var apiVersionPriorities = map[schema.GroupVersion]priority{ {Group: "batch", Version: "v1beta1"}: {group: 17400, version: 9}, {Group: "batch", Version: "v2alpha1"}: {group: 17400, version: 9}, {Group: "certificates.k8s.io", Version: "v1"}: {group: 17300, version: 15}, + {Group: "certificates.k8s.io", Version: "v1alpha1"}: {group: 17300, version: 1}, {Group: "networking.k8s.io", Version: "v1"}: {group: 17200, version: 15}, {Group: "networking.k8s.io", Version: "v1alpha1"}: {group: 17200, version: 1}, {Group: "policy", Version: "v1"}: {group: 17100, version: 15}, diff --git a/hack/lib/init.sh b/hack/lib/init.sh index 6497292e013..2f5a1fd7d9c 100755 --- a/hack/lib/init.sh +++ b/hack/lib/init.sh @@ -88,6 +88,7 @@ batch/v1 \ batch/v1beta1 \ certificates.k8s.io/v1 \ certificates.k8s.io/v1beta1 \ +certificates.k8s.io/v1alpha1 \ coordination.k8s.io/v1beta1 \ coordination.k8s.io/v1 \ discovery.k8s.io/v1 \ diff --git a/pkg/apis/certificates/install/install.go b/pkg/apis/certificates/install/install.go index 26ff90e836c..c4ba5a2ca4a 100644 --- a/pkg/apis/certificates/install/install.go +++ b/pkg/apis/certificates/install/install.go @@ -24,6 +24,7 @@ import ( "k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/apis/certificates" v1 "k8s.io/kubernetes/pkg/apis/certificates/v1" + "k8s.io/kubernetes/pkg/apis/certificates/v1alpha1" "k8s.io/kubernetes/pkg/apis/certificates/v1beta1" ) @@ -36,5 +37,6 @@ func Install(scheme *runtime.Scheme) { utilruntime.Must(certificates.AddToScheme(scheme)) utilruntime.Must(v1.AddToScheme(scheme)) utilruntime.Must(v1beta1.AddToScheme(scheme)) - utilruntime.Must(scheme.SetVersionPriority(v1.SchemeGroupVersion, v1beta1.SchemeGroupVersion)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(scheme.SetVersionPriority(v1.SchemeGroupVersion, v1beta1.SchemeGroupVersion, v1alpha1.SchemeGroupVersion)) } diff --git a/pkg/apis/certificates/register.go b/pkg/apis/certificates/register.go index a876251ca43..dfb00ad7e8c 100644 --- a/pkg/apis/certificates/register.go +++ b/pkg/apis/certificates/register.go @@ -47,6 +47,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &CertificateSigningRequest{}, &CertificateSigningRequestList{}, + &ClusterTrustBundle{}, + &ClusterTrustBundleList{}, ) return nil } diff --git a/pkg/apis/certificates/types.go b/pkg/apis/certificates/types.go index 730ec9c3ad4..cd9284e108a 100644 --- a/pkg/apis/certificates/types.go +++ b/pkg/apis/certificates/types.go @@ -224,3 +224,56 @@ const ( UsageMicrosoftSGC KeyUsage = "microsoft sgc" UsageNetscapeSGC KeyUsage = "netscape sgc" ) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterTrustBundle is a cluster-scoped container for X.509 trust anchors +// (root certificates). +// +// ClusterTrustBundle objects are considered to be readable by any authenticated +// user in the cluster. +// +// It can be optionally associated with a particular assigner, in which case it +// contains one valid set of trust anchors for that signer. Signers may have +// multiple associated ClusterTrustBundles; each is an independent set of trust +// anchors for that signer. +type ClusterTrustBundle struct { + metav1.TypeMeta + // +optional + metav1.ObjectMeta + + // Spec contains the signer (if any) and trust anchors. + // +optional + Spec ClusterTrustBundleSpec +} + +// ClusterTrustBundleSpec contains the signer and trust anchors. +type ClusterTrustBundleSpec struct { + // SignerName indicates the associated signer, if any. + SignerName string + + // TrustBundle contains the individual X.509 trust anchors for this + // bundle, as PEM bundle of PEM-wrapped, DER-formatted X.509 certificates. + // + // The data must consist only of PEM certificate blocks that parse as valid + // X.509 certificates. Each certificate must include a basic constraints + // extension with the CA bit set. The API server will reject objects that + // contain duplicate certificates, or that use PEM block headers. + // + // Users of ClusterTrustBundles, including Kubelet, are free to reorder and + // deduplicate certificate blocks in this file according to their own logic, + // as well as to drop PEM block headers and inter-block data. + TrustBundle string +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterTrustBundleList is a collection of ClusterTrustBundle objects +type ClusterTrustBundleList struct { + metav1.TypeMeta + // +optional + metav1.ListMeta + + // Items is a collection of ClusterTrustBundle objects + Items []ClusterTrustBundle +} diff --git a/pkg/apis/certificates/v1alpha1/conversion.go b/pkg/apis/certificates/v1alpha1/conversion.go new file mode 100644 index 00000000000..1f34d3dd9dc --- /dev/null +++ b/pkg/apis/certificates/v1alpha1/conversion.go @@ -0,0 +1,37 @@ +/* +Copyright 2022 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 v1alpha1 + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" +) + +func addConversionFuncs(scheme *runtime.Scheme) error { + return scheme.AddFieldLabelConversionFunc( + SchemeGroupVersion.WithKind("ClusterTrustBundle"), + func(label, value string) (string, string, error) { + switch label { + case "metadata.name", "spec.signerName": + return label, value, nil + default: + return "", "", fmt.Errorf("field label not supported: %s", label) + } + }, + ) +} diff --git a/pkg/apis/certificates/v1alpha1/defaults.go b/pkg/apis/certificates/v1alpha1/defaults.go new file mode 100644 index 00000000000..f081177faa6 --- /dev/null +++ b/pkg/apis/certificates/v1alpha1/defaults.go @@ -0,0 +1,23 @@ +/* +Copyright 2022 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 v1alpha1 + +import "k8s.io/apimachinery/pkg/runtime" + +func addDefaultingFuncs(scheme *runtime.Scheme) error { + return RegisterDefaults(scheme) +} diff --git a/pkg/apis/certificates/v1alpha1/doc.go b/pkg/apis/certificates/v1alpha1/doc.go new file mode 100644 index 00000000000..0233e7f78f6 --- /dev/null +++ b/pkg/apis/certificates/v1alpha1/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2022 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. +*/ + +// +k8s:conversion-gen=k8s.io/kubernetes/pkg/apis/certificates +// +k8s:conversion-gen-external-types=k8s.io/api/certificates/v1alpha1 +// +k8s:defaulter-gen=TypeMeta +// +k8s:defaulter-gen-input=k8s.io/api/certificates/v1alpha1 + +// +groupName=certificates.k8s.io + +package v1alpha1 // import "k8s.io/kubernetes/pkg/apis/certificates/v1alpha1" diff --git a/pkg/apis/certificates/v1alpha1/register.go b/pkg/apis/certificates/v1alpha1/register.go new file mode 100644 index 00000000000..a8d2d15e7d5 --- /dev/null +++ b/pkg/apis/certificates/v1alpha1/register.go @@ -0,0 +1,43 @@ +/* +Copyright 2022 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 v1alpha1 + +import ( + certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name used in this package. +const GroupName = "certificates.k8s.io" + +// SchemeGroupVersion is the group and version used in this package. +var SchemeGroupVersion = schema.GroupVersion{ + Group: GroupName, + Version: "v1alpha1", +} + +var ( + localSchemeBuilder = &certificatesv1alpha1.SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addDefaultingFuncs, addConversionFuncs) +} diff --git a/pkg/apis/certificates/validation/validation.go b/pkg/apis/certificates/validation/validation.go index 806e5c2d4f7..7f26551b0ae 100644 --- a/pkg/apis/certificates/validation/validation.go +++ b/pkg/apis/certificates/validation/validation.go @@ -25,6 +25,7 @@ import ( v1 "k8s.io/api/core/v1" apiequality "k8s.io/apimachinery/pkg/api/equality" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" "k8s.io/apimachinery/pkg/util/diff" "k8s.io/apimachinery/pkg/util/sets" utilvalidation "k8s.io/apimachinery/pkg/util/validation" @@ -197,7 +198,7 @@ func validateCertificateSigningRequest(csr *certificates.CertificateSigningReque if !opts.allowLegacySignerName && csr.Spec.SignerName == certificates.LegacyUnknownSignerName { allErrs = append(allErrs, field.Invalid(specPath.Child("signerName"), csr.Spec.SignerName, "the legacy signerName is not allowed via this API version")) } else { - allErrs = append(allErrs, ValidateCertificateSigningRequestSignerName(specPath.Child("signerName"), csr.Spec.SignerName)...) + allErrs = append(allErrs, ValidateSignerName(specPath.Child("signerName"), csr.Spec.SignerName)...) } if csr.Spec.ExpirationSeconds != nil && *csr.Spec.ExpirationSeconds < 600 { allErrs = append(allErrs, field.Invalid(specPath.Child("expirationSeconds"), *csr.Spec.ExpirationSeconds, "may not specify a duration less than 600 seconds (10 minutes)")) @@ -272,7 +273,7 @@ func validateConditions(fldPath *field.Path, csr *certificates.CertificateSignin // The max length of a namespace name is 63 characters (DNS1123Label max length) // The max length of a resource name is 253 characters (DNS1123Subdomain max length) // We then add an additional 2 characters to account for the one '.' and one '/'. -func ValidateCertificateSigningRequestSignerName(fldPath *field.Path, signerName string) field.ErrorList { +func ValidateSignerName(fldPath *field.Path, signerName string) field.ErrorList { var el field.ErrorList if len(signerName) == 0 { el = append(el, field.Required(fldPath, "")) @@ -537,3 +538,129 @@ func hasDuplicateUsage(usages []certificates.KeyUsage) bool { } return false } + +// We require your name to be prefixed by .spec.signerName +func validateClusterTrustBundleName(signerName string) func(name string, prefix bool) []string { + return func(name string, isPrefix bool) []string { + if signerName == "" { + if strings.Contains(name, ":") { + return []string{"ClusterTrustBundle without signer name must not have \":\" in its name"} + } + return apimachineryvalidation.NameIsDNSSubdomain(name, isPrefix) + } + + requiredPrefix := strings.ReplaceAll(signerName, "/", ":") + ":" + if !strings.HasPrefix(name, requiredPrefix) { + return []string{fmt.Sprintf("ClusterTrustBundle for signerName %s must be named with prefix %s", signerName, requiredPrefix)} + } + return apimachineryvalidation.NameIsDNSSubdomain(strings.TrimPrefix(name, requiredPrefix), isPrefix) + } +} + +type ValidateClusterTrustBundleOptions struct { + SuppressBundleParsing bool +} + +// ValidateClusterTrustBundle runs all validation checks on bundle. +func ValidateClusterTrustBundle(bundle *certificates.ClusterTrustBundle, opts ValidateClusterTrustBundleOptions) field.ErrorList { + var allErrors field.ErrorList + + metaErrors := apivalidation.ValidateObjectMeta(&bundle.ObjectMeta, false, validateClusterTrustBundleName(bundle.Spec.SignerName), field.NewPath("metadata")) + allErrors = append(allErrors, metaErrors...) + + if bundle.Spec.SignerName != "" { + signerNameErrors := ValidateSignerName(field.NewPath("spec", "signerName"), bundle.Spec.SignerName) + allErrors = append(allErrors, signerNameErrors...) + } + + if !opts.SuppressBundleParsing { + pemErrors := validateTrustBundle(field.NewPath("spec", "trustBundle"), bundle.Spec.TrustBundle) + allErrors = append(allErrors, pemErrors...) + } + + return allErrors +} + +// ValidateClusterTrustBundleUpdate runs all update validation checks on an +// update. +func ValidateClusterTrustBundleUpdate(newBundle, oldBundle *certificates.ClusterTrustBundle) field.ErrorList { + // If the caller isn't changing the TrustBundle field, don't parse it. + // This helps smoothly handle changes in Go's PEM or X.509 parsing + // libraries. + opts := ValidateClusterTrustBundleOptions{} + if newBundle.Spec.TrustBundle == oldBundle.Spec.TrustBundle { + opts.SuppressBundleParsing = true + } + + var allErrors field.ErrorList + allErrors = append(allErrors, ValidateClusterTrustBundle(newBundle, opts)...) + allErrors = append(allErrors, apivalidation.ValidateObjectMetaUpdate(&newBundle.ObjectMeta, &oldBundle.ObjectMeta, field.NewPath("metadata"))...) + allErrors = append(allErrors, apivalidation.ValidateImmutableField(newBundle.Spec.SignerName, oldBundle.Spec.SignerName, field.NewPath("spec", "signerName"))...) + return allErrors +} + +// validateTrustBundle rejects intra-block headers, blocks +// that don't parse as X.509 CA certificates, and duplicate trust anchors. It +// requires that at least one trust anchor is provided. +func validateTrustBundle(path *field.Path, in string) field.ErrorList { + var allErrors field.ErrorList + + blockDedupe := map[string][]int{} + + rest := []byte(in) + var b *pem.Block + i := -1 + for { + b, rest = pem.Decode(rest) + if b == nil { + break + } + i++ + + if b.Type != "CERTIFICATE" { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("entry %d has bad block type: %v", i, b.Type))) + continue + } + + if len(b.Headers) != 0 { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("entry %d has PEM block headers", i))) + continue + } + + cert, err := x509.ParseCertificate(b.Bytes) + if err != nil { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("entry %d does not parse as X.509", i))) + continue + } + + if !cert.IsCA { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("entry %d does not have the CA bit set", i))) + continue + } + + if !cert.BasicConstraintsValid { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("entry %d has invalid basic constraints", i))) + continue + } + + blockDedupe[string(b.Bytes)] = append(blockDedupe[string(b.Bytes)], i) + } + + // If we had a malformed block, don't also output potentially-redundant + // errors about duplicate or missing trust anchors. + if len(allErrors) != 0 { + return allErrors + } + + if len(blockDedupe) == 0 { + allErrors = append(allErrors, field.Invalid(path, "", "at least one trust anchor must be provided")) + } + + for _, indices := range blockDedupe { + if len(indices) > 1 { + allErrors = append(allErrors, field.Invalid(path, "", fmt.Sprintf("duplicate trust anchor (indices %v)", indices))) + } + } + + return allErrors +} diff --git a/pkg/apis/certificates/validation/validation_test.go b/pkg/apis/certificates/validation/validation_test.go index 339b69abd0b..988b4c5be6f 100644 --- a/pkg/apis/certificates/validation/validation_test.go +++ b/pkg/apis/certificates/validation/validation_test.go @@ -23,12 +23,15 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "math/big" + mathrand "math/rand" "reflect" "regexp" "strings" "testing" "time" + "github.com/google/go-cmp/cmp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation/field" @@ -1095,6 +1098,471 @@ func Test_validateCertificateSigningRequestOptions(t *testing.T) { } } +func mustMakeCertificate(t *testing.T, template *x509.Certificate) []byte { + gen := mathrand.New(mathrand.NewSource(12345)) + + pub, priv, err := ed25519.GenerateKey(gen) + if err != nil { + t.Fatalf("Error while generating key: %v", err) + } + + cert, err := x509.CreateCertificate(gen, template, template, pub, priv) + if err != nil { + t.Fatalf("Error while making certificate: %v", err) + } + + return cert +} + +func mustMakePEMBlock(blockType string, headers map[string]string, data []byte) string { + return string(pem.EncodeToMemory(&pem.Block{ + Type: blockType, + Headers: headers, + Bytes: data, + })) +} + +func TestValidateClusterTrustBundle(t *testing.T) { + goodCert1 := mustMakeCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "root1", + }, + IsCA: true, + BasicConstraintsValid: true, + }) + + goodCert2 := mustMakeCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "root2", + }, + IsCA: true, + BasicConstraintsValid: true, + }) + + badNotCACert := mustMakeCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "root3", + }, + }) + + goodCert1Block := string(mustMakePEMBlock("CERTIFICATE", nil, goodCert1)) + goodCert2Block := string(mustMakePEMBlock("CERTIFICATE", nil, goodCert2)) + + goodCert1AlternateBlock := strings.ReplaceAll(goodCert1Block, "\n", "\n\t\n") + + badNotCACertBlock := string(mustMakePEMBlock("CERTIFICATE", nil, badNotCACert)) + + badBlockHeadersBlock := string(mustMakePEMBlock("CERTIFICATE", map[string]string{"key": "value"}, goodCert1)) + badBlockTypeBlock := string(mustMakePEMBlock("NOTACERTIFICATE", nil, goodCert1)) + badNonParseableBlock := string(mustMakePEMBlock("CERTIFICATE", nil, []byte("this is not a certificate"))) + + testCases := []struct { + description string + bundle *capi.ClusterTrustBundle + opts ValidateClusterTrustBundleOptions + wantErrors field.ErrorList + }{ + { + description: "valid, no signer name", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block, + }, + }, + }, + { + description: "invalid, no signer name, invalid name", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:bar:foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("metadata", "name"), "k8s.io:bar:foo", "ClusterTrustBundle without signer name must not have \":\" in its name"), + }, + }, + { + description: "valid, with signer name", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + }, + { + description: "invalid, with signer name, missing name prefix", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "look-ma-no-prefix", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("metadata", "name"), "look-ma-no-prefix", "ClusterTrustBundle for signerName k8s.io/foo must be named with prefix k8s.io:foo:"), + }, + }, + { + description: "invalid, with signer name, empty name suffix", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("metadata", "name"), "k8s.io:foo:", `a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`), + }, + }, + { + description: "invalid, with signer name, bad name suffix", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:123notvalidDNSSubdomain", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("metadata", "name"), "k8s.io:foo:123notvalidDNSSubdomain", `a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`), + }, + }, + { + description: "valid, with signer name, with inter-block garbage", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:abc", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: "garbage\n" + goodCert1Block + "\ngarbage\n" + goodCert2Block, + }, + }, + }, + { + description: "invalid, no signer name, no trust anchors", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{}, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "at least one trust anchor must be provided"), + }, + }, + { + description: "invalid, no trust anchors", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:abc", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "at least one trust anchor must be provided"), + }, + }, + { + description: "invalid, bad signer name", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid:foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "invalid", + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "signerName"), "invalid", "must be a fully qualified domain and path of the form 'example.com/signer-name'"), + }, + }, + { + description: "invalid, no blocks", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: "non block garbage", + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "at least one trust anchor must be provided"), + }, + }, + { + description: "invalid, bad block type", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block + "\n" + badBlockTypeBlock, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "entry 1 has bad block type: NOTACERTIFICATE"), + }, + }, + { + description: "invalid, block with headers", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block + "\n" + badBlockHeadersBlock, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "entry 1 has PEM block headers"), + }, + }, + { + description: "invalid, cert is not a CA cert", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: badNotCACertBlock, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "entry 0 does not have the CA bit set"), + }, + }, + { + description: "invalid, duplicated blocks", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block + "\n" + goodCert1AlternateBlock, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "duplicate trust anchor (indices [0 1])"), + }, + }, + { + description: "invalid, non-certificate entry", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: goodCert1Block + "\n" + badNonParseableBlock, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "entry 1 does not parse as X.509"), + }, + }, + { + description: "allow any old garbage in the PEM field if we suppress parsing", + bundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: capi.ClusterTrustBundleSpec{ + TrustBundle: "garbage", + }, + }, + opts: ValidateClusterTrustBundleOptions{ + SuppressBundleParsing: true, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + gotErrors := ValidateClusterTrustBundle(tc.bundle, tc.opts) + if diff := cmp.Diff(gotErrors, tc.wantErrors); diff != "" { + t.Fatalf("Unexpected error output from Validate; diff (-got +want)\n%s", diff) + } + + // When there are no changes to the object, + // ValidateClusterTrustBundleUpdate should not report errors about + // the TrustBundle field. + tc.bundle.ObjectMeta.ResourceVersion = "1" + newBundle := tc.bundle.DeepCopy() + newBundle.ObjectMeta.ResourceVersion = "2" + gotErrors = ValidateClusterTrustBundleUpdate(newBundle, tc.bundle) + + var filteredWantErrors field.ErrorList + for _, err := range tc.wantErrors { + if err.Field != "spec.trustBundle" { + filteredWantErrors = append(filteredWantErrors, err) + } + } + + if diff := cmp.Diff(gotErrors, filteredWantErrors); diff != "" { + t.Fatalf("Unexpected error output from ValidateUpdate; diff (-got +want)\n%s", diff) + } + }) + } +} + +func TestValidateClusterTrustBundleUpdate(t *testing.T) { + goodCert1 := mustMakeCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "root1", + }, + IsCA: true, + BasicConstraintsValid: true, + }) + + goodCert2 := mustMakeCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "root2", + }, + IsCA: true, + BasicConstraintsValid: true, + }) + + goodCert1Block := string(mustMakePEMBlock("CERTIFICATE", nil, goodCert1)) + goodCert2Block := string(mustMakePEMBlock("CERTIFICATE", nil, goodCert2)) + + testCases := []struct { + description string + oldBundle, newBundle *capi.ClusterTrustBundle + wantErrors field.ErrorList + }{ + { + description: "changing signer name disallowed", + oldBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + newBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/bar", + TrustBundle: goodCert1Block, + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("metadata", "name"), "k8s.io:foo:bar", "ClusterTrustBundle for signerName k8s.io/bar must be named with prefix k8s.io:bar:"), + field.Invalid(field.NewPath("spec", "signerName"), "k8s.io/bar", "field is immutable"), + }, + }, + { + description: "adding certificate allowed", + oldBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + newBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block + "\n" + goodCert2Block, + }, + }, + }, + { + description: "emptying trustBundle disallowed", + oldBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + newBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: "", + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "at least one trust anchor must be provided"), + }, + }, + { + description: "emptying trustBundle (replace with non-block garbage) disallowed", + oldBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: goodCert1Block, + }, + }, + newBundle: &capi.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8s.io:foo:bar", + }, + Spec: capi.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: "non block garbage", + }, + }, + wantErrors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "trustBundle"), "", "at least one trust anchor must be provided"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + tc.oldBundle.ObjectMeta.ResourceVersion = "1" + tc.newBundle.ObjectMeta.ResourceVersion = "2" + gotErrors := ValidateClusterTrustBundleUpdate(tc.newBundle, tc.oldBundle) + if diff := cmp.Diff(gotErrors, tc.wantErrors); diff != "" { + t.Errorf("Unexpected error output from ValidateUpdate; diff (-got +want)\n%s", diff) + } + }) + } +} + var ( validCertificate = []byte(` Leading non-PEM content diff --git a/pkg/controlplane/instance.go b/pkg/controlplane/instance.go index 540f416a5ca..0216ac7e8a6 100644 --- a/pkg/controlplane/instance.go +++ b/pkg/controlplane/instance.go @@ -40,6 +40,7 @@ import ( batchapiv1 "k8s.io/api/batch/v1" batchapiv1beta1 "k8s.io/api/batch/v1beta1" certificatesapiv1 "k8s.io/api/certificates/v1" + certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" coordinationapiv1 "k8s.io/api/coordination/v1" apiv1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" @@ -734,6 +735,7 @@ var ( apiserverinternalv1alpha1.SchemeGroupVersion, authenticationv1alpha1.SchemeGroupVersion, resourcev1alpha2.SchemeGroupVersion, + certificatesv1alpha1.SchemeGroupVersion, networkingapiv1alpha1.SchemeGroupVersion, storageapiv1alpha1.SchemeGroupVersion, flowcontrolv1alpha1.SchemeGroupVersion, diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index 0d08585be67..dae17977504 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -67,6 +67,12 @@ const ( // Enables dual-stack --node-ip in kubelet with external cloud providers CloudDualStackNodeIPs featuregate.Feature = "CloudDualStackNodeIPs" + // owner: @ahmedtd + // alpha: v1.26 + // + // Enable ClusterTrustBundle objects and Kubelet integration. + ClusterTrustBundle featuregate.Feature = "ClusterTrustBundle" + // owner: @szuecs // alpha: v1.12 // @@ -934,6 +940,8 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS CloudDualStackNodeIPs: {Default: false, PreRelease: featuregate.Alpha}, + ClusterTrustBundle: {Default: false, PreRelease: featuregate.Alpha}, + CPUCFSQuotaPeriod: {Default: false, PreRelease: featuregate.Alpha}, CPUManager: {Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // GA in 1.26 diff --git a/pkg/kubeapiserver/default_storage_factory_builder.go b/pkg/kubeapiserver/default_storage_factory_builder.go index b9f8fc75ed5..f7700aacffb 100644 --- a/pkg/kubeapiserver/default_storage_factory_builder.go +++ b/pkg/kubeapiserver/default_storage_factory_builder.go @@ -28,6 +28,7 @@ import ( "k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/apis/admissionregistration" "k8s.io/kubernetes/pkg/apis/apps" + "k8s.io/kubernetes/pkg/apis/certificates" api "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/apis/events" "k8s.io/kubernetes/pkg/apis/extensions" @@ -72,6 +73,7 @@ func NewStorageFactoryConfig() *StorageFactoryConfig { admissionregistration.Resource("validatingadmissionpolicybindings").WithVersion("v1alpha1"), networking.Resource("clustercidrs").WithVersion("v1alpha1"), networking.Resource("ipaddresses").WithVersion("v1alpha1"), + certificates.Resource("clustertrustbundles").WithVersion("v1alpha1"), } return &StorageFactoryConfig{ diff --git a/pkg/kubeapiserver/options/plugins.go b/pkg/kubeapiserver/options/plugins.go index ddca6953458..542da72b9b8 100644 --- a/pkg/kubeapiserver/options/plugins.go +++ b/pkg/kubeapiserver/options/plugins.go @@ -26,6 +26,7 @@ import ( "k8s.io/kubernetes/plugin/pkg/admission/alwayspullimages" "k8s.io/kubernetes/plugin/pkg/admission/antiaffinity" certapproval "k8s.io/kubernetes/plugin/pkg/admission/certificates/approval" + "k8s.io/kubernetes/plugin/pkg/admission/certificates/ctbattest" certsigning "k8s.io/kubernetes/plugin/pkg/admission/certificates/signing" certsubjectrestriction "k8s.io/kubernetes/plugin/pkg/admission/certificates/subjectrestriction" "k8s.io/kubernetes/plugin/pkg/admission/defaulttolerationseconds" @@ -90,6 +91,7 @@ var AllOrderedPlugins = []string{ runtimeclass.PluginName, // RuntimeClass certapproval.PluginName, // CertificateApproval certsigning.PluginName, // CertificateSigning + ctbattest.PluginName, // ClusterTrustBundleAttest certsubjectrestriction.PluginName, // CertificateSubjectRestriction defaultingressclass.PluginName, // DefaultIngressClass denyserviceexternalips.PluginName, // DenyServiceExternalIPs @@ -137,6 +139,7 @@ func RegisterAllAdmissionPlugins(plugins *admission.Plugins) { storageobjectinuseprotection.Register(plugins) certapproval.Register(plugins) certsigning.Register(plugins) + ctbattest.Register(plugins) certsubjectrestriction.Register(plugins) } @@ -158,6 +161,7 @@ func DefaultOffAdmissionPlugins() sets.String { runtimeclass.PluginName, // RuntimeClass certapproval.PluginName, // CertificateApproval certsigning.PluginName, // CertificateSigning + ctbattest.PluginName, // ClusterTrustBundleAttest certsubjectrestriction.PluginName, // CertificateSubjectRestriction defaultingressclass.PluginName, // DefaultIngressClass podsecurity.PluginName, // PodSecurity diff --git a/pkg/printers/internalversion/printers.go b/pkg/printers/internalversion/printers.go index 6afc8705063..3509a55f157 100644 --- a/pkg/printers/internalversion/printers.go +++ b/pkg/printers/internalversion/printers.go @@ -31,6 +31,7 @@ import ( autoscalingv2beta1 "k8s.io/api/autoscaling/v2beta1" batchv1 "k8s.io/api/batch/v1" batchv1beta1 "k8s.io/api/batch/v1beta1" + certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" certificatesv1beta1 "k8s.io/api/certificates/v1beta1" coordinationv1 "k8s.io/api/coordination/v1" apiv1 "k8s.io/api/core/v1" @@ -407,6 +408,13 @@ func AddHandlers(h printers.PrintHandler) { _ = h.TableHandler(certificateSigningRequestColumnDefinitions, printCertificateSigningRequest) _ = h.TableHandler(certificateSigningRequestColumnDefinitions, printCertificateSigningRequestList) + clusterTrustBundleColumnDefinitions := []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, + {Name: "SignerName", Type: "string", Description: certificatesv1alpha1.ClusterTrustBundleSpec{}.SwaggerDoc()["signerName"]}, + } + h.TableHandler(clusterTrustBundleColumnDefinitions, printClusterTrustBundle) + h.TableHandler(clusterTrustBundleColumnDefinitions, printClusterTrustBundleList) + leaseColumnDefinitions := []metav1.TableColumnDefinition{ {Name: "Name", Type: "string", Format: "name", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, {Name: "Holder", Type: "string", Description: coordinationv1.LeaseSpec{}.SwaggerDoc()["holderIdentity"]}, @@ -2095,6 +2103,30 @@ func printCertificateSigningRequestList(list *certificates.CertificateSigningReq return rows, nil } +func printClusterTrustBundle(obj *certificates.ClusterTrustBundle, options printers.GenerateOptions) ([]metav1.TableRow, error) { + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: obj}, + } + signerName := "" + if obj.Spec.SignerName != "" { + signerName = obj.Spec.SignerName + } + row.Cells = append(row.Cells, obj.Name, signerName) + return []metav1.TableRow{row}, nil +} + +func printClusterTrustBundleList(list *certificates.ClusterTrustBundleList, options printers.GenerateOptions) ([]metav1.TableRow, error) { + rows := make([]metav1.TableRow, 0, len(list.Items)) + for i := range list.Items { + r, err := printClusterTrustBundle(&list.Items[i], options) + if err != nil { + return nil, err + } + rows = append(rows, r...) + } + return rows, nil +} + func printComponentStatus(obj *api.ComponentStatus, options printers.GenerateOptions) ([]metav1.TableRow, error) { row := metav1.TableRow{ Object: runtime.RawExtension{Object: obj}, diff --git a/pkg/registry/certificates/clustertrustbundle/storage/storage.go b/pkg/registry/certificates/clustertrustbundle/storage/storage.go new file mode 100644 index 00000000000..f87e9d49a6a --- /dev/null +++ b/pkg/registry/certificates/clustertrustbundle/storage/storage.go @@ -0,0 +1,79 @@ +/* +Copyright 2022 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 storage + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + api "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/certificates/clustertrustbundle" +) + +// REST is a RESTStorage for ClusterTrustBundle. +type REST struct { + *genericregistry.Store +} + +var _ rest.StandardStorage = &REST{} +var _ rest.TableConvertor = &REST{} +var _ genericregistry.GenericStore = &REST{} + +// NewREST returns a RESTStorage object for ClusterTrustBundle objects. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) { + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &api.ClusterTrustBundle{} }, + NewListFunc: func() runtime.Object { return &api.ClusterTrustBundleList{} }, + DefaultQualifiedResource: api.Resource("clustertrustbundles"), + SingularQualifiedResource: api.Resource("clustertrustbundle"), + + CreateStrategy: clustertrustbundle.Strategy, + UpdateStrategy: clustertrustbundle.Strategy, + DeleteStrategy: clustertrustbundle.Strategy, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{ + RESTOptions: optsGetter, + AttrFunc: getAttrs, + } + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return &REST{store}, nil +} + +func getAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + bundle, ok := obj.(*api.ClusterTrustBundle) + if !ok { + return nil, nil, fmt.Errorf("not a clustertrustbundle") + } + + selectableFields := generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&bundle.ObjectMeta, false), fields.Set{ + "spec.signerName": bundle.Spec.SignerName, + }) + + return labels.Set(bundle.Labels), selectableFields, nil +} diff --git a/pkg/registry/certificates/clustertrustbundle/storage/storage_test.go b/pkg/registry/certificates/clustertrustbundle/storage/storage_test.go new file mode 100644 index 00000000000..13be0f80c92 --- /dev/null +++ b/pkg/registry/certificates/clustertrustbundle/storage/storage_test.go @@ -0,0 +1,250 @@ +/* +Copyright 2022 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 storage + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" + etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" + "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +const validCert1 = ` +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIUUW9bIIsHU61w3yQR6amBuVvRFvcwDQYJKoZIhvcNAQEL +BQAwXDELMAkGA1UEBhMCeHgxCjAIBgNVBAgMAXgxCjAIBgNVBAcMAXgxCjAIBgNV +BAoMAXgxCjAIBgNVBAsMAXgxCzAJBgNVBAMMAmNhMRAwDgYJKoZIhvcNAQkBFgF4 +MB4XDTIyMTAxODIzNTIyNFoXDTIzMTAxODIzNTIyNFowXDELMAkGA1UEBhMCeHgx +CjAIBgNVBAgMAXgxCjAIBgNVBAcMAXgxCjAIBgNVBAoMAXgxCjAIBgNVBAsMAXgx +CzAJBgNVBAMMAmNhMRAwDgYJKoZIhvcNAQkBFgF4MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA4PeK4SmlsNwpw97gTtjODQytUfyqhBIwdENwJUbc019Y +m3VTCRLCGXjUa22mV6/j7V+mZw114ePFYTiGAH+2dUzWAZOphvtzE5ttPuv6A6Zx +k2J69lNFwJ2fPd7XQIH7pEIXjiEBaszxKZKMsN9+jOGu6iFFAwYLMemFYDbZHuqb +OwdQcSEsy5wO2ANzFRuYzGXuNcS8jYLHftE8g2P+L0wXnV9eW6/lM2ZFxS/nzDJz +qtzrEvQrBsmskTNC8gCRRZ7askp3CVdPKjC90sxAPwhpi8JjJZxSe1Bn/WRHUz82 +GFytEIJNx9hJY2GI316zkxgTbsxfRQe4QLJN7sRtpwIDAQABo1MwUTAdBgNVHQ4E +FgQU9FGsI8t+cu68fGkhtvO9FtUd174wHwYDVR0jBBgwFoAU9FGsI8t+cu68fGkh +tvO9FtUd174wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAqDIp +In5h2xZfEZcijT3mjfG8Bo6taxM2biy1M7wEpmDrElmrjMLsflZepcjgkSoVz9hP +cSX/k9ls1zy1H799gcjs+afSpIa1N0nUIxAKF1RHsFa+dvXpSA8YdhUnbEcBnqx0 +vN2nDBFpdCSNf+EXNEj12+9ZJm6TLzx22f9vHyRCg4D36X3Rj1FCBWxhf0mSt3ek +5px3H53Xu42MqzZCiJc8/m+IqZHaixZS4bsayssaxif2fNxzAIZhgTygo8P8QGjI +rUmstMbg4PPq62x1yLAxEo+8XCg05saWZs384JE+K1SDqxobm51EROWVwi8jUrNC +9nojtkQ+jDZD+1Stiw== +-----END CERTIFICATE----- +` + +const validCert2 = ` +-----BEGIN CERTIFICATE----- +MIIC/jCCAeagAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl +cm5ldGVzMB4XDTIyMTAxOTIzMTY0MFoXDTMyMTAxNjIzMTY0MFowFTETMBEGA1UE +AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO+k +zbj35jHIjCd5mxP1FHMwMtvLFPeKUjtaLDP9Bs2jZ97Igmr7NTysn9QZkRP68/XX +j993Y8tOLg71N4vRggWiYP+T9Xfo0uHZJmzADKx5XkuC4Gqv79dUdb8IKfAbX9HB +ffGmWRnZLLTu8Bv/vfyl0CfE64a57DK+CzNJDwdK46CYYUnEH6Wb9finYrMQ+PLG +Oi2c0J4KAYc1WTId5npNwouzf/IMD33PvuXfE7r+/pDbP8u/X03e7U0cc9l7KRxr +3gpRQemCG74yRuy1dd3lJ1YCD8q96xVVZimGebnJ0IHi+lORRa2ix/o3OzW3FaP+ +6kzHU6VnBRDr2rAhMh0CAwEAAaNZMFcwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB +/wQFMAMBAf8wHQYDVR0OBBYEFGUVOLM74t1TVoZjifsLl3Rwt1A6MBUGA1UdEQQO +MAyCCmt1YmVybmV0ZXMwDQYJKoZIhvcNAQELBQADggEBANHnPVDemZqRybYPN1as +Ywxi3iT1I3Wma1rZyxTWeIq8Ik0gnyvbtCD1cFB/5QU1xPW09YnmIFM/E73RIeWT +RmCNMgOGmegYxBQRe4UvmwWGJzKNA66c0MBmd2LDHrQlrvdewOCR667Sm9krsGt1 +tS/t6N/uBXeRSkXKEDXa+jOpYrV3Oq3IntG6zUeCrVbrH2Bs9Ma5fU00TwK3ylw5 +Ww8KzYdQaxxrLaiRRtFcpM9dFH/vwxl1QUa5vjHcmUjxmZunEmXKplATyLT0FXDw +JAo8AuwuuwRh2o+o8SxwzzA+/EBrIREgcv5uIkD352QnfGkEvGu6JOPGZVyd/kVg +KA0= +-----END CERTIFICATE----- +` + +func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorageForResource(t, certificates.SchemeGroupVersion.WithResource("clustertrustbundles").GroupResource()) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "clustertrustbundles", + } + storage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return storage, server +} + +func TestCreate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + validBundle := &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + TrustBundle: validCert1, + }, + } + + invalidBundle := &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + // Empty TrustBundle is invalid. + }, + } + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestCreate(validBundle, invalidBundle) +} + +func TestUpdate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestUpdate( + &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + TrustBundle: validCert1, + }, + }, + // Valid update + func(object runtime.Object) runtime.Object { + bundle := object.(*certificates.ClusterTrustBundle) + bundle.Spec.TrustBundle = strings.Join([]string{validCert1, validCert2}, "\n") + return bundle + }, + // Invalid update + func(object runtime.Object) runtime.Object { + bundle := object.(*certificates.ClusterTrustBundle) + bundle.Spec.TrustBundle = "" + return bundle + }, + ) +} + +func TestDelete(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestDelete( + &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + TrustBundle: validCert1, + }, + }, + ) +} + +func TestGet(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestGet( + &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + TrustBundle: validCert1, + }, + }, + ) +} + +func TestList(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestList( + &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + TrustBundle: validCert1, + }, + }, + ) +} + +func TestWatch(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + test := genericregistrytest.New(t, storage.Store) + test = test.ClusterScope() + + test.TestWatch( + &certificates.ClusterTrustBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb1", + }, + Spec: certificates.ClusterTrustBundleSpec{ + SignerName: "k8s.io/foo", + TrustBundle: validCert1, + }, + }, + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"foo": "bar"}, + }, + // matching fields + []fields.Set{ + {"metadata.name": "ctb1"}, + }, + // not matching fields + []fields.Set{ + {"metadata.name": "bar"}, + }, + ) +} diff --git a/pkg/registry/certificates/clustertrustbundle/strategy.go b/pkg/registry/certificates/clustertrustbundle/strategy.go new file mode 100644 index 00000000000..5708ede08ec --- /dev/null +++ b/pkg/registry/certificates/clustertrustbundle/strategy.go @@ -0,0 +1,81 @@ +/* +Copyright 2022 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 clustertrustbundle provides Registry interface and its RESTStorage +// implementation for storing ClusterTrustBundle objects. +package clustertrustbundle // import "k8s.io/kubernetes/pkg/registry/certificates/clustertrustbundle" + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/certificates" + certvalidation "k8s.io/kubernetes/pkg/apis/certificates/validation" +) + +// strategy implements behavior for ClusterTrustBundles. +type strategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +// Strategy is the create, update, and delete strategy for ClusterTrustBundles. +var Strategy = strategy{legacyscheme.Scheme, names.SimpleNameGenerator} + +var _ rest.RESTCreateStrategy = Strategy +var _ rest.RESTUpdateStrategy = Strategy +var _ rest.RESTDeleteStrategy = Strategy + +func (strategy) NamespaceScoped() bool { + return false +} + +func (strategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {} + +func (strategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + bundle := obj.(*certificates.ClusterTrustBundle) + return certvalidation.ValidateClusterTrustBundle(bundle, certvalidation.ValidateClusterTrustBundleOptions{}) +} + +func (strategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (strategy) Canonicalize(obj runtime.Object) {} + +func (strategy) AllowCreateOnUpdate() bool { + return false +} + +func (s strategy) PrepareForUpdate(ctx context.Context, new, old runtime.Object) {} + +func (s strategy) ValidateUpdate(ctx context.Context, new, old runtime.Object) field.ErrorList { + newBundle := new.(*certificates.ClusterTrustBundle) + oldBundle := old.(*certificates.ClusterTrustBundle) + return certvalidation.ValidateClusterTrustBundleUpdate(newBundle, oldBundle) +} + +func (strategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (strategy) AllowUnconditionalUpdate() bool { + return false +} diff --git a/pkg/registry/certificates/clustertrustbundle/strategy_test.go b/pkg/registry/certificates/clustertrustbundle/strategy_test.go new file mode 100644 index 00000000000..b172180298d --- /dev/null +++ b/pkg/registry/certificates/clustertrustbundle/strategy_test.go @@ -0,0 +1,48 @@ +/* +Copyright 2022 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 clustertrustbundle + +import ( + "context" + "testing" + + "k8s.io/kubernetes/pkg/apis/certificates" +) + +func TestWarningsOnCreate(t *testing.T) { + if warnings := Strategy.WarningsOnCreate(context.Background(), &certificates.ClusterTrustBundle{}); warnings != nil { + t.Errorf("Got %v, want nil", warnings) + } +} + +func TestAllowCreateOnUpdate(t *testing.T) { + if Strategy.AllowCreateOnUpdate() != false { + t.Errorf("Got true, want false") + } +} + +func TestWarningsOnUpdate(t *testing.T) { + if warnings := Strategy.WarningsOnUpdate(context.Background(), &certificates.ClusterTrustBundle{}, &certificates.ClusterTrustBundle{}); warnings != nil { + t.Errorf("Got %v, want nil", warnings) + } +} + +func TestAllowUnconditionalUpdate(t *testing.T) { + if Strategy.AllowUnconditionalUpdate() != false { + t.Errorf("Got true, want false") + } +} diff --git a/pkg/registry/certificates/rest/storage_certificates.go b/pkg/registry/certificates/rest/storage_certificates.go index ce65458e494..2eaef4bb202 100644 --- a/pkg/registry/certificates/rest/storage_certificates.go +++ b/pkg/registry/certificates/rest/storage_certificates.go @@ -18,13 +18,18 @@ package rest import ( certificatesapiv1 "k8s.io/api/certificates/v1" + certificatesapiv1alpha1 "k8s.io/api/certificates/v1alpha1" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" serverstorage "k8s.io/apiserver/pkg/server/storage" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/klog/v2" "k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/pkg/features" certificatestore "k8s.io/kubernetes/pkg/registry/certificates/certificates/storage" + clustertrustbundlestore "k8s.io/kubernetes/pkg/registry/certificates/clustertrustbundle/storage" ) type RESTStorageProvider struct{} @@ -40,17 +45,22 @@ func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorag apiGroupInfo.VersionedResourcesStorageMap[certificatesapiv1.SchemeGroupVersion.Version] = storageMap } + if storageMap, err := p.v1alpha1Storage(apiResourceConfigSource, restOptionsGetter); err != nil { + return genericapiserver.APIGroupInfo{}, err + } else if len(storageMap) > 0 { + apiGroupInfo.VersionedResourcesStorageMap[certificatesapiv1alpha1.SchemeGroupVersion.Version] = storageMap + } + return apiGroupInfo, nil } func (p RESTStorageProvider) v1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { storage := map[string]rest.Storage{} - // certificatesigningrequests if resource := "certificatesigningrequests"; apiResourceConfigSource.ResourceEnabled(certificatesapiv1.SchemeGroupVersion.WithResource(resource)) { csrStorage, csrStatusStorage, csrApprovalStorage, err := certificatestore.NewREST(restOptionsGetter) if err != nil { - return storage, err + return nil, err } storage[resource] = csrStorage storage[resource+"/status"] = csrStatusStorage @@ -60,6 +70,24 @@ func (p RESTStorageProvider) v1Storage(apiResourceConfigSource serverstorage.API return storage, nil } +func (p RESTStorageProvider) v1alpha1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { + storage := map[string]rest.Storage{} + + if resource := "clustertrustbundles"; apiResourceConfigSource.ResourceEnabled(certificatesapiv1alpha1.SchemeGroupVersion.WithResource(resource)) { + if utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundle) { + bundleStorage, err := clustertrustbundlestore.NewREST(restOptionsGetter) + if err != nil { + return nil, err + } + storage[resource] = bundleStorage + } else { + klog.Warning("ClusterTrustBundle storage is disabled because the ClusterTrustBundle feature gate is disabled") + } + } + + return storage, nil +} + func (p RESTStorageProvider) GroupName() string { return certificates.GroupName } diff --git a/pkg/registry/registrytest/etcd.go b/pkg/registry/registrytest/etcd.go index ba2fb1e194b..990d6b3d277 100644 --- a/pkg/registry/registrytest/etcd.go +++ b/pkg/registry/registrytest/etcd.go @@ -42,11 +42,11 @@ func NewEtcdStorageForResource(t *testing.T, resource schema.GroupResource) (*st completedConfig.APIResourceConfig = serverstorage.NewResourceConfig() factory, err := completedConfig.New() if err != nil { - t.Fatal(err) + t.Fatalf("Error while making storage factory: %v", err) } resourceConfig, err := factory.NewConfig(resource) if err != nil { - t.Fatal(err) + t.Fatalf("Error while finding storage destination: %v", err) } return resourceConfig, server } diff --git a/plugin/pkg/admission/certificates/ctbattest/admission.go b/plugin/pkg/admission/certificates/ctbattest/admission.go new file mode 100644 index 00000000000..6b100ee328e --- /dev/null +++ b/plugin/pkg/admission/certificates/ctbattest/admission.go @@ -0,0 +1,133 @@ +/* +Copyright 2022 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 ctbattest + +import ( + "context" + "fmt" + "io" + + "k8s.io/apiserver/pkg/admission" + genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/component-base/featuregate" + "k8s.io/klog/v2" + api "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/pkg/features" + "k8s.io/kubernetes/plugin/pkg/admission/certificates" +) + +const PluginName = "ClusterTrustBundleAttest" + +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +// Plugin is the ClusterTrustBundle attest plugin. +// +// In order to create or update a ClusterTrustBundle that sets signerName, +// you must have the following permission: group=certificates.k8s.io +// resource=signers resourceName= verb=attest. +type Plugin struct { + *admission.Handler + authz authorizer.Authorizer + + inspectedFeatureGates bool + enabled bool +} + +var _ admission.ValidationInterface = &Plugin{} +var _ admission.InitializationValidator = &Plugin{} +var _ genericadmissioninit.WantsAuthorizer = &Plugin{} +var _ genericadmissioninit.WantsFeatures = &Plugin{} + +func NewPlugin() *Plugin { + return &Plugin{ + Handler: admission.NewHandler(admission.Create, admission.Update), + } +} + +// SetAuthorizer sets the plugin's authorizer. +func (p *Plugin) SetAuthorizer(authz authorizer.Authorizer) { + p.authz = authz +} + +// InspectFeatureGates implements WantsFeatures. +func (p *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) { + p.enabled = featureGates.Enabled(features.ClusterTrustBundle) + p.inspectedFeatureGates = true +} + +// ValidateInitialization checks that the plugin was initialized correctly. +func (p *Plugin) ValidateInitialization() error { + if p.authz == nil { + return fmt.Errorf("%s requires an authorizer", PluginName) + } + if !p.inspectedFeatureGates { + return fmt.Errorf("%s did not see feature gates", PluginName) + } + return nil +} + +var clusterTrustBundleGroupResource = api.Resource("clustertrustbundles") + +func (p *Plugin) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error { + if !p.enabled { + return nil + } + if a.GetResource().GroupResource() != clusterTrustBundleGroupResource { + return nil + } + + newBundle, ok := a.GetObject().(*api.ClusterTrustBundle) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type ClusterTrustBundle, got: %T", a.GetOldObject())) + } + + // Unlike CSRs, it's OK to validate against the *new* object, because + // updates to signer name will be rejected during validation. For defense + // in depth, reject attempts to change signer at this layer as well. + // + // We want to use the new object because we also need to perform the signer + // name permission check on *create*. + + if a.GetOperation() == admission.Update { + oldBundle, ok := a.GetOldObject().(*api.ClusterTrustBundle) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type ClusterTrustBundle, got: %T", a.GetOldObject())) + } + + if oldBundle.Spec.SignerName != newBundle.Spec.SignerName { + return admission.NewForbidden(a, fmt.Errorf("changing signerName is forbidden")) + } + } + + // If signer name isn't specified, we don't need to perform the + // attest check. + if newBundle.Spec.SignerName == "" { + return nil + } + + if !certificates.IsAuthorizedForSignerName(ctx, p.authz, a.GetUserInfo(), "attest", newBundle.Spec.SignerName) { + klog.V(4).Infof("user not permitted to attest ClusterTrustBundle %q with signerName %q", newBundle.Name, newBundle.Spec.SignerName) + return admission.NewForbidden(a, fmt.Errorf("user not permitted to attest for signerName %q", newBundle.Spec.SignerName)) + } + + return nil +} diff --git a/plugin/pkg/admission/certificates/ctbattest/admission_test.go b/plugin/pkg/admission/certificates/ctbattest/admission_test.go new file mode 100644 index 00000000000..e8f844c3464 --- /dev/null +++ b/plugin/pkg/admission/certificates/ctbattest/admission_test.go @@ -0,0 +1,306 @@ +/* +Copyright 2022 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 ctbattest + +import ( + "context" + "errors" + "fmt" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/pkg/features" +) + +func TestPluginValidate(t *testing.T) { + tests := []struct { + description string + clusterTrustBundleFeatureEnabled bool + attributes admission.Attributes + allowedName string + allowed bool + authzErr error + }{ + { + description: "wrong type on create", + clusterTrustBundleFeatureEnabled: true, + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundleList{}, + operation: admission.Create, + }, + allowed: false, + }, + { + description: "wrong type on update", + clusterTrustBundleFeatureEnabled: true, + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundleList{}, + operation: admission.Update, + }, + allowed: false, + }, + { + description: "reject requests if looking up permissions fails", + clusterTrustBundleFeatureEnabled: true, + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Update, + }, + authzErr: errors.New("forced error"), + allowed: false, + }, + { + description: "should allow create if no signer name is specified", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{}, + }, + operation: admission.Create, + }, + allowed: true, + }, + { + description: "should allow update if no signer name is specified", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + oldObj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{}, + }, + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{}, + }, + operation: admission.Update, + }, + allowed: true, + }, + { + description: "should allow create if user is authorized for specific signerName", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Create, + }, + allowed: true, + }, + { + description: "should allow update if user is authorized for specific signerName", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + oldObj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Update, + }, + allowed: true, + }, + { + description: "should allow create if user is authorized with wildcard", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/*", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Create, + }, + allowed: true, + }, + { + description: "should allow update if user is authorized with wildcard", + clusterTrustBundleFeatureEnabled: true, + allowedName: "abc.com/*", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + oldObj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Update, + }, + allowed: true, + }, + { + description: "should deny create if user does not have permission for this signerName", + clusterTrustBundleFeatureEnabled: true, + allowedName: "notabc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Create, + }, + allowed: false, + }, + { + description: "should deny update if user does not have permission for this signerName", + clusterTrustBundleFeatureEnabled: true, + allowedName: "notabc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("clustertrustbundles"), + obj: &certificatesapi.ClusterTrustBundle{ + Spec: certificatesapi.ClusterTrustBundleSpec{ + SignerName: "abc.com/xyz", + }, + }, + operation: admission.Update, + }, + allowed: false, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + p := Plugin{ + authz: fakeAuthorizer{ + t: t, + verb: "attest", + allowedName: tc.allowedName, + decision: authorizer.DecisionAllow, + err: tc.authzErr, + }, + } + + defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.ClusterTrustBundle, tc.clusterTrustBundleFeatureEnabled)() + p.InspectFeatureGates(feature.DefaultFeatureGate) + + err := p.Validate(context.Background(), tc.attributes, nil) + if err == nil && !tc.allowed { + t.Errorf("Expected authorization policy to reject ClusterTrustBundle but it was allowed") + } + if err != nil && tc.allowed { + t.Errorf("Expected authorization policy to accept ClusterTrustBundle but it was rejected: %v", err) + } + }) + } +} + +type fakeAuthorizer struct { + t *testing.T + verb string + allowedName string + decision authorizer.Decision + err error +} + +func (f fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + if f.err != nil { + return f.decision, "forced error", f.err + } + if a.GetVerb() != f.verb { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised verb '%s'", a.GetVerb()), nil + } + if a.GetAPIGroup() != "certificates.k8s.io" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised groupName '%s'", a.GetAPIGroup()), nil + } + if a.GetAPIVersion() != "*" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised apiVersion '%s'", a.GetAPIVersion()), nil + } + if a.GetResource() != "signers" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised resource '%s'", a.GetResource()), nil + } + if a.GetName() != f.allowedName { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised resource name '%s'", a.GetName()), nil + } + if !a.IsResourceRequest() { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised IsResourceRequest '%t'", a.IsResourceRequest()), nil + } + return f.decision, "", nil +} + +type testAttributes struct { + resource schema.GroupResource + subresource string + operation admission.Operation + obj, oldObj runtime.Object + name string + + admission.Attributes // nil panic if any other methods called +} + +func (t *testAttributes) GetResource() schema.GroupVersionResource { + return t.resource.WithVersion("ignored") +} + +func (t *testAttributes) GetSubresource() string { + return t.subresource +} + +func (t *testAttributes) GetObject() runtime.Object { + return t.obj +} + +func (t *testAttributes) GetOldObject() runtime.Object { + return t.oldObj +} + +func (t *testAttributes) GetName() string { + return t.name +} + +func (t *testAttributes) GetOperation() admission.Operation { + return t.operation +} + +func (t *testAttributes) GetUserInfo() user.Info { + return &user.DefaultInfo{Name: "ignored"} +} diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index 1e172c05dfa..d4446990fa5 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -180,6 +180,10 @@ func NodeRules() []rbacv1.PolicyRule { if utilfeature.DefaultFeatureGate.Enabled(features.DynamicResourceAllocation) { nodePolicyRules = append(nodePolicyRules, rbacv1helpers.NewRule("get").Groups(resourceGroup).Resources("resourceclaims").RuleOrDie()) } + // Kubelet needs access to ClusterTrustBundles to support the pemTrustAnchors volume type. + if utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundle) { + nodePolicyRules = append(nodePolicyRules, rbacv1helpers.NewRule("get", "list", "watch").Groups(certificatesGroup).Resources("clustertrustbundles").RuleOrDie()) + } return nodePolicyRules } @@ -585,6 +589,16 @@ func ClusterRoles() []rbacv1.ClusterRole { Rules: kubeSchedulerRules, }) + // Default ClusterRole to allow reading ClusterTrustBundle objects + if utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundle) { + roles = append(roles, rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "system:cluster-trust-bundle-discovery"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule(Read...).Groups(certificatesGroup).Resources("clustertrustbundles").RuleOrDie(), + }, + }) + } + addClusterRoleLabel(roles) return roles } @@ -625,6 +639,11 @@ func ClusterRoleBindings() []rbacv1.ClusterRoleBinding { rbacv1helpers.NewClusterBinding("system:service-account-issuer-discovery").Groups(serviceaccount.AllServiceAccountsGroup).BindingOrDie(), ) + // Service accounts can read ClusterTrustBundle objects. + if utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundle) { + rolebindings = append(rolebindings, rbacv1helpers.NewClusterBinding("system:cluster-trust-bundle-discovery").Groups(serviceaccount.AllServiceAccountsGroup).BindingOrDie()) + } + addClusterRoleBindingLabel(rolebindings) return rolebindings diff --git a/staging/src/k8s.io/api/certificates/v1alpha1/doc.go b/staging/src/k8s.io/api/certificates/v1alpha1/doc.go new file mode 100644 index 00000000000..d83d0e82076 --- /dev/null +++ b/staging/src/k8s.io/api/certificates/v1alpha1/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2022 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. +*/ + +// +k8s:deepcopy-gen=package +// +k8s:protobuf-gen=package +// +k8s:openapi-gen=true +// +k8s:prerelease-lifecycle-gen=true + +// +groupName=certificates.k8s.io + +package v1alpha1 // import "k8s.io/api/certificates/v1alpha1" diff --git a/staging/src/k8s.io/api/certificates/v1alpha1/register.go b/staging/src/k8s.io/api/certificates/v1alpha1/register.go new file mode 100644 index 00000000000..7288ed9a3e8 --- /dev/null +++ b/staging/src/k8s.io/api/certificates/v1alpha1/register.go @@ -0,0 +1,61 @@ +/* +Copyright 2022 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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "certificates.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +// Kind takes an unqualified kind and returns a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // SchemeBuilder is the scheme builder with scheme init functions to run for this API package + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + + localSchemeBuilder = &SchemeBuilder + + // AddToScheme is a global function that registers this API group & version to a scheme + AddToScheme = localSchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &ClusterTrustBundle{}, + &ClusterTrustBundleList{}, + ) + + // Add the watch version that applies + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/staging/src/k8s.io/api/certificates/v1alpha1/types.go b/staging/src/k8s.io/api/certificates/v1alpha1/types.go new file mode 100644 index 00000000000..0ad1763d63b --- /dev/null +++ b/staging/src/k8s.io/api/certificates/v1alpha1/types.go @@ -0,0 +1,106 @@ +/* +Copyright 2023 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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +k8s:prerelease-lifecycle-gen:introduced=1.26 +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterTrustBundle is a cluster-scoped container for X.509 trust anchors +// (root certificates). +// +// ClusterTrustBundle objects are considered to be readable by any authenticated +// user in the cluster, because they can be mounted by pods using the +// `clusterTrustBundle` projection. All service accounts have read access to +// ClusterTrustBundles by default. Users who only have namespace-level access +// to a cluster can read ClusterTrustBundles by impersonating a serviceaccount +// that they have access to. +// +// It can be optionally associated with a particular assigner, in which case it +// contains one valid set of trust anchors for that signer. Signers may have +// multiple associated ClusterTrustBundles; each is an independent set of trust +// anchors for that signer. Admission control is used to enforce that only users +// with permissions on the signer can create or modify the corresponding bundle. +type ClusterTrustBundle struct { + metav1.TypeMeta `json:",inline"` + + // metadata contains the object metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // spec contains the signer (if any) and trust anchors. + Spec ClusterTrustBundleSpec `json:"spec" protobuf:"bytes,2,opt,name=spec"` +} + +// ClusterTrustBundleSpec contains the signer and trust anchors. +type ClusterTrustBundleSpec struct { + // signerName indicates the associated signer, if any. + // + // In order to create or update a ClusterTrustBundle that sets signerName, + // you must have the following cluster-scoped permission: + // group=certificates.k8s.io resource=signers resourceName= + // verb=attest. + // + // If signerName is not empty, then the ClusterTrustBundle object must be + // named with the signer name as a prefix (translating slashes to colons). + // For example, for the signer name `example.com/foo`, valid + // ClusterTrustBundle object names include `example.com:foo:abc` and + // `example.com:foo:v1`. + // + // If signerName is empty, then the ClusterTrustBundle object's name must + // not have such a prefix. + // + // List/watch requests for ClusterTrustBundles can filter on this field + // using a `spec.signerName=NAME` field selector. + // + // +optional + SignerName string `json:"signerName,omitempty" protobuf:"bytes,1,opt,name=signerName"` + + // trustBundle contains the individual X.509 trust anchors for this + // bundle, as PEM bundle of PEM-wrapped, DER-formatted X.509 certificates. + // + // The data must consist only of PEM certificate blocks that parse as valid + // X.509 certificates. Each certificate must include a basic constraints + // extension with the CA bit set. The API server will reject objects that + // contain duplicate certificates, or that use PEM block headers. + // + // Users of ClusterTrustBundles, including Kubelet, are free to reorder and + // deduplicate certificate blocks in this file according to their own logic, + // as well as to drop PEM block headers and inter-block data. + TrustBundle string `json:"trustBundle" protobuf:"bytes,2,opt,name=trustBundle"` +} + +// +k8s:prerelease-lifecycle-gen:introduced=1.26 +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterTrustBundleList is a collection of ClusterTrustBundle objects +type ClusterTrustBundleList struct { + metav1.TypeMeta `json:",inline"` + + // metadata contains the list metadata. + // + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // items is a collection of ClusterTrustBundle objects + Items []ClusterTrustBundle `json:"items" protobuf:"bytes,2,rep,name=items"` +} diff --git a/staging/src/k8s.io/api/roundtrip_test.go b/staging/src/k8s.io/api/roundtrip_test.go index 59fa7f06d16..5fab14139f7 100644 --- a/staging/src/k8s.io/api/roundtrip_test.go +++ b/staging/src/k8s.io/api/roundtrip_test.go @@ -41,6 +41,7 @@ import ( batchv1 "k8s.io/api/batch/v1" batchv1beta1 "k8s.io/api/batch/v1beta1" certificatesv1 "k8s.io/api/certificates/v1" + certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" certificatesv1beta1 "k8s.io/api/certificates/v1beta1" coordinationv1 "k8s.io/api/coordination/v1" coordinationv1beta1 "k8s.io/api/coordination/v1beta1" @@ -105,6 +106,7 @@ var groups = []runtime.SchemeBuilder{ batchv1.SchemeBuilder, certificatesv1.SchemeBuilder, certificatesv1beta1.SchemeBuilder, + certificatesv1alpha1.SchemeBuilder, coordinationv1.SchemeBuilder, coordinationv1beta1.SchemeBuilder, corev1.SchemeBuilder, diff --git a/vendor/modules.txt b/vendor/modules.txt index 00ef84b5ed9..3e9bf905103 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1234,6 +1234,7 @@ k8s.io/api/autoscaling/v2beta2 k8s.io/api/batch/v1 k8s.io/api/batch/v1beta1 k8s.io/api/certificates/v1 +k8s.io/api/certificates/v1alpha1 k8s.io/api/certificates/v1beta1 k8s.io/api/coordination/v1 k8s.io/api/coordination/v1beta1 @@ -1576,6 +1577,7 @@ k8s.io/client-go/applyconfigurations/autoscaling/v2beta2 k8s.io/client-go/applyconfigurations/batch/v1 k8s.io/client-go/applyconfigurations/batch/v1beta1 k8s.io/client-go/applyconfigurations/certificates/v1 +k8s.io/client-go/applyconfigurations/certificates/v1alpha1 k8s.io/client-go/applyconfigurations/certificates/v1beta1 k8s.io/client-go/applyconfigurations/coordination/v1 k8s.io/client-go/applyconfigurations/coordination/v1beta1 @@ -1640,6 +1642,7 @@ k8s.io/client-go/informers/batch/v1 k8s.io/client-go/informers/batch/v1beta1 k8s.io/client-go/informers/certificates k8s.io/client-go/informers/certificates/v1 +k8s.io/client-go/informers/certificates/v1alpha1 k8s.io/client-go/informers/certificates/v1beta1 k8s.io/client-go/informers/coordination k8s.io/client-go/informers/coordination/v1 @@ -1726,6 +1729,8 @@ k8s.io/client-go/kubernetes/typed/batch/v1beta1 k8s.io/client-go/kubernetes/typed/batch/v1beta1/fake k8s.io/client-go/kubernetes/typed/certificates/v1 k8s.io/client-go/kubernetes/typed/certificates/v1/fake +k8s.io/client-go/kubernetes/typed/certificates/v1alpha1 +k8s.io/client-go/kubernetes/typed/certificates/v1alpha1/fake k8s.io/client-go/kubernetes/typed/certificates/v1beta1 k8s.io/client-go/kubernetes/typed/certificates/v1beta1/fake k8s.io/client-go/kubernetes/typed/coordination/v1 @@ -1802,6 +1807,7 @@ k8s.io/client-go/listers/autoscaling/v2beta2 k8s.io/client-go/listers/batch/v1 k8s.io/client-go/listers/batch/v1beta1 k8s.io/client-go/listers/certificates/v1 +k8s.io/client-go/listers/certificates/v1alpha1 k8s.io/client-go/listers/certificates/v1beta1 k8s.io/client-go/listers/coordination/v1 k8s.io/client-go/listers/coordination/v1beta1