diff --git a/pkg/tls/resolve_tls_config_test.go b/pkg/tls/resolve_tls_config_test.go new file mode 100644 index 00000000..382a1e83 --- /dev/null +++ b/pkg/tls/resolve_tls_config_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls + +import ( + "context" + "crypto/tls" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" + libgocrypto "github.com/openshift/library-go/pkg/crypto" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type getErrorClient struct { + client.Client + err error +} + +func (c getErrorClient) Get(_ context.Context, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return c.err +} + +var _ = Describe("ResolveTLSConfig", Label("apigroup:config.openshift.io"), func() { + var ( + testCtx context.Context + cancelTestCtx context.CancelFunc + ) + + BeforeEach(func() { + testCtx, cancelTestCtx = context.WithTimeout(ctx, 10*time.Second) + deleteAPIServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + } + err := k8sClient.Delete(testCtx, deleteAPIServer) + Expect(err == nil || apierrors.IsNotFound(err)).To(BeTrue(), "deleting APIServer %q before the test", APIServerName) + }) + + AfterEach(func() { + defer cancelTestCtx() + cleanupCtx, cancelCleanupCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCleanupCtx() + + deleteAPIServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + } + err := k8sClient.Delete(cleanupCtx, deleteAPIServer) + Expect(err == nil || apierrors.IsNotFound(err)).To(BeTrue(), "deleting APIServer %q after the test", APIServerName) + }) + + It("falls back to the default profile when the APIServer resource is unavailable", func() { + result, err := ResolveTLSConfig(testCtx, cfg) + + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration without an APIServer resource") + Expect(result).NotTo(BeNil(), "resolver should return a result when the APIServer resource is unavailable") + Expect(result.TLSConfig).NotTo(BeNil(), "resolver should return a TLS configuration function") + Expect(result.ProfileSpec).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileIntermediateType]), "fallback profile should be the Intermediate profile") + Expect(result.AdherencePolicy).To(Equal(configv1.TLSAdherencePolicyNoOpinion), "fallback adherence policy should be NoOpinion") + Expect(result.APIServerAvailable).To(BeTrue(), "a missing APIServer singleton should still register a watcher for recovery") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion))), "fallback should apply the Intermediate minimum TLS version") + }) + + It("uses the Old cluster profile under strict adherence", func() { + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{Type: configv1.TLSProfileOldType}, + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + } + Expect(k8sClient.Create(testCtx, apiServer)).To(Succeed(), "creating an APIServer with strict Old TLS adherence") + + result, err := ResolveTLSConfig(testCtx, cfg) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with strict adherence") + Expect(result.ProfileSpec).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileOldType]), "resolved profile should be the cluster Old profile") + Expect(result.AdherencePolicy).To(Equal(configv1.TLSAdherencePolicyStrictAllComponents), "resolved adherence policy should be StrictAllComponents") + Expect(result.APIServerAvailable).To(BeTrue(), "the available APIServer should register a watcher") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(configv1.TLSProfiles[configv1.TLSProfileOldType].MinTLSVersion))), "strict adherence should preserve the Old minimum TLS version") + }) + + It("uses a custom profile with a legacy TLS version under strict adherence", func() { + customProfile := configv1.TLSProfileSpec{ + Ciphers: []string{"AES128-SHA"}, + MinTLSVersion: configv1.VersionTLS11, + } + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: customProfile, + }, + }, + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + } + Expect(k8sClient.Create(testCtx, apiServer)).To(Succeed(), "creating an APIServer with a legacy custom TLS profile") + + result, err := ResolveTLSConfig(testCtx, cfg) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with a legacy custom profile") + Expect(result.ProfileSpec).To(Equal(customProfile), "resolved profile should match the cluster custom profile") + Expect(result.APIServerAvailable).To(BeTrue(), "the available APIServer should register a watcher") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(customProfile.MinTLSVersion))), "strict adherence should preserve the custom minimum TLS version") + Expect(tlsConfig.CipherSuites).To(ContainElement(tls.TLS_RSA_WITH_AES_128_CBC_SHA), "strict adherence should preserve the configured legacy cipher") + }) + + It("uses the default profile when adherence is NoOpinion", func() { + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{Type: configv1.TLSProfileOldType}, + }, + } + Expect(k8sClient.Create(testCtx, apiServer)).To(Succeed(), "creating an APIServer with NoOpinion TLS adherence") + + result, err := ResolveTLSConfig(testCtx, cfg) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with NoOpinion adherence") + Expect(result.ProfileSpec).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileOldType]), "resolved metadata should preserve the cluster profile") + Expect(result.AdherencePolicy).To(Equal(configv1.TLSAdherencePolicyNoOpinion), "resolved adherence policy should be NoOpinion") + Expect(result.APIServerAvailable).To(BeTrue(), "the available APIServer should register a watcher") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion))), "NoOpinion adherence should apply the Intermediate minimum TLS version") + }) + + It("uses the default profile when adherence is LegacyAdheringComponentsOnly", func() { + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{Type: configv1.TLSProfileOldType}, + TLSAdherence: configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly, + }, + } + Expect(k8sClient.Create(testCtx, apiServer)).To(Succeed(), "creating an APIServer with legacy TLS adherence") + + result, err := ResolveTLSConfig(testCtx, cfg) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with legacy adherence") + Expect(result.AdherencePolicy).To(Equal(configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly), "resolved adherence policy should be LegacyAdheringComponentsOnly") + Expect(result.APIServerAvailable).To(BeTrue(), "the available APIServer should register a watcher") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion))), "legacy adherence should apply the Intermediate minimum TLS version") + }) + + It("uses a custom profile when adherence is StrictAllComponents", func() { + customProfile := configv1.TLSProfileSpec{ + Ciphers: []string{"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}, + MinTLSVersion: configv1.VersionTLS13, + } + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: customProfile, + }, + }, + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + } + Expect(k8sClient.Create(testCtx, apiServer)).To(Succeed(), "creating an APIServer with a strict custom TLS profile") + + result, err := ResolveTLSConfig(testCtx, cfg) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with a strict custom profile") + Expect(result.ProfileSpec).To(Equal(customProfile), "resolved profile should match the cluster custom profile") + Expect(result.APIServerAvailable).To(BeTrue(), "the available APIServer should register a watcher") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(uint16(tls.VersionTLS13)), "custom profile should set the configured TLS 1.3 minimum") + Expect(tlsConfig.CipherSuites).To(BeNil(), "TLS 1.3 cipher suites should remain managed by Go") + }) + + It("preserves an observed malformed profile while applying the default profile", func() { + malformedProfile := configv1.TLSProfileSpec{ + Ciphers: []string{"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}, + MinTLSVersion: configv1.TLSProtocolVersion("VersionTLS99"), + } + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: malformedProfile, + }, + }, + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + } + fakeClient := clientfake.NewClientBuilder().WithScheme(testScheme).WithObjects(apiServer).Build() + + result, err := resolveTLSConfig(testCtx, fakeClient) + Expect(err).NotTo(HaveOccurred(), "resolving TLS configuration with a malformed minimum version") + Expect(result.ProfileSpec).To(Equal(malformedProfile), "watcher state should preserve the observed malformed profile") + Expect(result.APIServerAvailable).To(BeTrue(), "the APIServer object was available") + + tlsConfig := &tls.Config{} + result.TLSConfig(tlsConfig) + Expect(tlsConfig.MinVersion).To(Equal(libgocrypto.TLSVersionOrDie(string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion))), "malformed profile should use the default minimum TLS version") + }) +}) + +var _ = Describe("ResolveTLSConfig APIServer errors", func() { + newErrorClient := func(err error) client.Client { + return getErrorClient{ + Client: clientfake.NewClientBuilder().WithScheme(testScheme).Build(), + err: err, + } + } + + It("falls back for missing and transient APIServer errors", func() { + resource := schema.GroupVersionResource{ + Group: "config.openshift.io", Version: "v1", Resource: "apiservers", + } + testCases := []struct { + name string + err error + available bool + }{ + {name: "no match", err: &meta.NoResourceMatchError{PartialResource: resource}}, + {name: "not found", err: apierrors.NewNotFound(schema.GroupResource{Group: resource.Group, Resource: resource.Resource}, APIServerName), available: true}, + {name: "service unavailable", err: apierrors.NewServiceUnavailable("unavailable"), available: true}, + {name: "timeout", err: apierrors.NewTimeoutError("timeout", 1), available: true}, + {name: "too many requests", err: apierrors.NewTooManyRequests("busy", 1), available: true}, + {name: "deadline exceeded", err: context.DeadlineExceeded, available: true}, + } + + for _, testCase := range testCases { + result, err := resolveTLSConfig(context.Background(), newErrorClient(testCase.err)) + Expect(err).NotTo(HaveOccurred(), testCase.name) + Expect(result).NotTo(BeNil(), testCase.name) + Expect(result.APIServerAvailable).To(Equal(testCase.available), testCase.name) + } + }) + + It("returns errors for forbidden and unexpected APIServer failures", func() { + resource := schema.GroupResource{Group: "config.openshift.io", Resource: "apiservers"} + testCases := []struct { + name string + err error + }{ + {name: "forbidden", err: apierrors.NewForbidden(resource, APIServerName, errors.New("forbidden"))}, + {name: "unauthorized", err: apierrors.NewUnauthorized("unauthorized")}, + {name: "internal error", err: apierrors.NewInternalError(errors.New("internal error"))}, + } + + for _, testCase := range testCases { + result, err := resolveTLSConfig(context.Background(), newErrorClient(testCase.err)) + Expect(err).To(HaveOccurred(), testCase.name) + Expect(result).To(BeNil(), testCase.name) + } + }) +}) diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go index ce1e8c7d..d67ddb1c 100644 --- a/pkg/tls/tls.go +++ b/pkg/tls/tls.go @@ -25,7 +25,12 @@ import ( configv1 "github.com/openshift/api/config/v1" libgocrypto "github.com/openshift/library-go/pkg/crypto" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" ) const ( @@ -46,15 +51,15 @@ var ( // FetchAPIServerTLSProfile fetches the TLS profile spec configured in APIServer. // If no profile is configured, the default profile is returned. func FetchAPIServerTLSProfile(ctx context.Context, k8sClient client.Client) (configv1.TLSProfileSpec, error) { - apiServer := &configv1.APIServer{} - key := client.ObjectKey{Name: APIServerName} - - if err := k8sClient.Get(ctx, key, apiServer); err != nil { + apiServer, err := fetchAPIServer(ctx, k8sClient) + if err != nil { + key := client.ObjectKey{Name: APIServerName} return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get APIServer %q: %w", key.String(), err) } profile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) if err != nil { + key := client.ObjectKey{Name: APIServerName} return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get TLS profile from APIServer %q: %w", key.String(), err) } @@ -64,16 +69,25 @@ func FetchAPIServerTLSProfile(ctx context.Context, k8sClient client.Client) (con // FetchAPIServerTLSAdherencePolicy fetches the TLS adherence policy configured in APIServer. // If no policy is configured, the default policy is returned. func FetchAPIServerTLSAdherencePolicy(ctx context.Context, k8sClient client.Client) (configv1.TLSAdherencePolicy, error) { - apiServer := &configv1.APIServer{} - key := client.ObjectKey{Name: APIServerName} - - if err := k8sClient.Get(ctx, key, apiServer); err != nil { + apiServer, err := fetchAPIServer(ctx, k8sClient) + if err != nil { + key := client.ObjectKey{Name: APIServerName} return configv1.TLSAdherencePolicyNoOpinion, fmt.Errorf("failed to get APIServer %q: %w", key.String(), err) } return apiServer.Spec.TLSAdherence, nil } +func fetchAPIServer(ctx context.Context, k8sClient client.Client) (*configv1.APIServer, error) { + apiServer := &configv1.APIServer{} + + if err := k8sClient.Get(ctx, client.ObjectKey{Name: APIServerName}, apiServer); err != nil { + return nil, err + } + + return apiServer, nil +} + // GetTLSProfileSpec returns TLSProfileSpec for the given profile. // If no profile is configured, the default profile is returned. func GetTLSProfileSpec(profile *configv1.TLSSecurityProfile) (configv1.TLSProfileSpec, error) { @@ -106,6 +120,125 @@ func GetTLSProfileSpec(profile *configv1.TLSSecurityProfile) (configv1.TLSProfil return profile.Custom.TLSProfileSpec, nil } +// ConfigResult holds the results of ResolveTLSConfig. +type ConfigResult struct { + // TLSConfig is a function that configures a tls.Config based on the resolved profile. + TLSConfig func(*tls.Config) + + // ProfileSpec is the profile specification observed in APIServer. It remains + // unchanged when TLSConfig uses the default because the observed profile is + // also used to initialize SecurityProfileWatcher. + ProfileSpec configv1.TLSProfileSpec + + // AdherencePolicy is the resolved TLS adherence policy. + AdherencePolicy configv1.TLSAdherencePolicy + + // APIServerAvailable reports whether the APIServer API is available for a + // SecurityProfileWatcher. It is false when the API is absent, and true when + // the resource was read, is temporarily absent, or a transient read error occurred. + APIServerAvailable bool +} + +// ResolveTLSConfig resolves TLS configuration for an operator. +// It fetches the cluster TLS profile and adherence policy, applies the appropriate +// configuration, and returns a ConfigResult that can be used with the SecurityProfileWatcher. +// +// If the OpenShift API is absent or temporarily unavailable, this function falls back to +// default configurations. Permission and unexpected API errors are returned to the caller. +func ResolveTLSConfig(ctx context.Context, restConfig *rest.Config) (*ConfigResult, error) { + // The default controller-runtime scheme does not include OpenShift API types. + scheme := runtime.NewScheme() + if err := configv1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add OpenShift config API to scheme: %w", err) + } + + kubeClient, err := client.New(restConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + + return resolveTLSConfig(ctx, kubeClient) +} + +func resolveTLSConfig(ctx context.Context, kubeClient client.Client) (*ConfigResult, error) { + logger := log.FromContext(ctx) + + defaultProfile := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + profileSpec := defaultProfile + + // Read the APIServer resource once so the profile and adherence policy come from + // the same resource version. + adherencePolicy := configv1.TLSAdherencePolicyNoOpinion + apiServerAvailable := true + apiServer, fetchErr := fetchAPIServer(ctx, kubeClient) + if fetchErr != nil { + shouldFallback, available := classifyAPIServerFetchError(fetchErr) + if !shouldFallback { + key := client.ObjectKey{Name: APIServerName} + return nil, fmt.Errorf("failed to get APIServer %q: %w", key.String(), fetchErr) + } + + apiServerAvailable = available + logger.Info("APIServer TLS configuration unavailable, using default TLS settings") + } else { + adherencePolicy = apiServer.Spec.TLSAdherence + fetchedProfile, profileErr := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + if profileErr != nil { + logger.Info("APIServer TLS profile is invalid, using the default profile") + } else { + profileSpec = fetchedProfile + } + } + + // NoOpinion and LegacyAdheringComponentsOnly preserve the legacy behavior of + // using the component default. Unknown values honor the cluster profile for + // forward-compatible, more secure behavior, matching library-go's helper. + profileToApply := defaultProfile + if adherencePolicy != configv1.TLSAdherencePolicyNoOpinion && + adherencePolicy != configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly { + // The cluster profile is authoritative when strict adherence is requested. + profileToApply = profileSpec + } + + // NewTLSConfigFromProfile uses TLSVersionOrDie, so only reject an invalid + // version that would otherwise panic. Valid legacy versions remain supported. + if _, err := libgocrypto.TLSVersion(string(profileToApply.MinTLSVersion)); err != nil { + logger.Info("APIServer TLS profile has an invalid minimum TLS version, using the default profile") + profileToApply = defaultProfile + } + + tlsConfigFn, unsupportedCiphers := NewTLSConfigFromProfile(profileToApply) + if len(unsupportedCiphers) > 0 { + logger.Info("TLS profile contains unsupported ciphers that will be ignored", "count", len(unsupportedCiphers)) + } + + return &ConfigResult{ + TLSConfig: tlsConfigFn, + ProfileSpec: profileSpec, + AdherencePolicy: adherencePolicy, + APIServerAvailable: apiServerAvailable, + }, nil +} + +func classifyAPIServerFetchError(err error) (shouldFallback, apiServerAvailable bool) { + if meta.IsNoMatchError(err) { + return true, false + } + if apierrors.IsNotFound(err) { + return true, true + } + + if apierrors.IsServiceUnavailable(err) || + apierrors.IsTimeout(err) || + apierrors.IsServerTimeout(err) || + apierrors.IsTooManyRequests(err) || + errors.Is(err, context.DeadlineExceeded) { + return true, true + } + + return false, false +} + // NewTLSConfigFromProfile returns a function that configures a tls.Config based on the provided TLSProfileSpec, // along with any cipher names from the profile that are not supported by the library-go crypto package. // The returned function is intended to be used with controller-runtime's TLSOpts.