diff --git a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go index 371d8afd2..f02091e7c 100644 --- a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go +++ b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go @@ -360,13 +360,67 @@ type AuthenticationConfig struct { // JWT authentication configuration. // Enables authentication using external JWT tokens from OIDC providers. // Supports multiple JWT authenticators for different identity providers. - JWT []apiserverv1beta1.JWTAuthenticator `json:"jwt,omitempty"` + // Each entry may optionally reference a CA certificate from a Kubernetes + // Secret or ConfigMap instead of inlining the PEM content. + JWT []JWTAuthenticatorConfig `json:"jwt,omitempty"` // Automatic user provisioning configuration, this is useful for creating // users authenticated by external identity providers in Jumpstarter. AutoProvisioning AutoProvisioningConfig `json:"autoProvisioning,omitempty"` } +// JWTAuthenticatorConfig extends the standard Kubernetes JWTAuthenticator with +// support for referencing CA certificates from Kubernetes Secrets or ConfigMaps. +// The operator resolves the reference at reconcile time and injects the PEM content +// into the controller ConfigMap, so CA rotations are picked up automatically. +type JWTAuthenticatorConfig struct { + apiserverv1beta1.JWTAuthenticator `json:",inline"` + + // CertificateAuthoritySecret references a Kubernetes Secret containing the CA + // certificate PEM for the OIDC issuer. The operator reads the specified key and + // injects the PEM content as the certificateAuthority for this authenticator. + // When the Secret changes, the operator reconciles and updates the ConfigMap. + // Takes precedence over CertificateAuthorityConfigMap when both are set. + // +optional + CertificateAuthoritySecret *SecretKeySelector `json:"certificateAuthoritySecret,omitempty"` + + // CertificateAuthorityConfigMap references a Kubernetes ConfigMap containing the + // CA certificate PEM for the OIDC issuer. The operator reads the specified key and + // injects the PEM content as the certificateAuthority for this authenticator. + // When the ConfigMap changes, the operator reconciles and updates the ConfigMap. + // +optional + CertificateAuthorityConfigMap *ConfigMapKeySelector `json:"certificateAuthorityConfigMap,omitempty"` +} + +// SecretKeySelector references a key within a Kubernetes Secret. +type SecretKeySelector struct { + // Name of the Secret containing the CA certificate. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Key within the Secret that holds the PEM-encoded CA certificate. + // Defaults to "tls.crt", which is the standard key used by cert-manager. + // +kubebuilder:default=tls.crt + // +optional + Key string `json:"key,omitempty"` +} + +// ConfigMapKeySelector references a key within a Kubernetes ConfigMap. +type ConfigMapKeySelector struct { + // Name of the ConfigMap containing the CA certificate. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Key within the ConfigMap that holds the PEM-encoded CA certificate. + // Defaults to "ca.crt", which is the standard key used by kube-root-ca.crt + // and cert-manager CA bundles. + // +kubebuilder:default=ca.crt + // +optional + Key string `json:"key,omitempty"` +} + // AutoProvisioningConfig defines auto provisioning configuration. type AutoProvisioningConfig struct { // Enable auto provisioning. diff --git a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go index 104c2b45f..1fe4e92b5 100644 --- a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -24,7 +24,6 @@ import ( "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -34,7 +33,7 @@ func (in *AuthenticationConfig) DeepCopyInto(out *AuthenticationConfig) { out.K8s = in.K8s if in.JWT != nil { in, out := &in.JWT, &out.JWT - *out = make([]v1beta1.JWTAuthenticator, len(*in)) + *out = make([]JWTAuthenticatorConfig, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -116,6 +115,21 @@ func (in *ClusterIPConfig) DeepCopy() *ClusterIPConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapKeySelector) DeepCopyInto(out *ConfigMapKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeySelector. +func (in *ConfigMapKeySelector) DeepCopy() *ConfigMapKeySelector { + if in == nil { + return nil + } + out := new(ConfigMapKeySelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ControllerConfig) DeepCopyInto(out *ControllerConfig) { *out = *in @@ -338,6 +352,32 @@ func (in *IssuerReference) DeepCopy() *IssuerReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTAuthenticatorConfig) DeepCopyInto(out *JWTAuthenticatorConfig) { + *out = *in + in.JWTAuthenticator.DeepCopyInto(&out.JWTAuthenticator) + if in.CertificateAuthoritySecret != nil { + in, out := &in.CertificateAuthoritySecret, &out.CertificateAuthoritySecret + *out = new(SecretKeySelector) + **out = **in + } + if in.CertificateAuthorityConfigMap != nil { + in, out := &in.CertificateAuthorityConfigMap, &out.CertificateAuthorityConfigMap + *out = new(ConfigMapKeySelector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTAuthenticatorConfig. +func (in *JWTAuthenticatorConfig) DeepCopy() *JWTAuthenticatorConfig { + if in == nil { + return nil + } + out := new(JWTAuthenticatorConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Jumpstarter) DeepCopyInto(out *Jumpstarter) { *out = *in @@ -645,6 +685,21 @@ func (in *RoutersConfig) DeepCopy() *RoutersConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeySelector. +func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { + if in == nil { + return nil + } + out := new(SecretKeySelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SelfSignedConfig) DeepCopyInto(out *SelfSignedConfig) { *out = *in diff --git a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml index 09b2c925c..daa5fea1a 100644 --- a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml +++ b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml @@ -92,10 +92,58 @@ spec: JWT authentication configuration. Enables authentication using external JWT tokens from OIDC providers. Supports multiple JWT authenticators for different identity providers. + Each entry may optionally reference a CA certificate from a Kubernetes + Secret or ConfigMap instead of inlining the PEM content. items: - description: JWTAuthenticator provides the configuration for - a single JWT authenticator. + description: |- + JWTAuthenticatorConfig extends the standard Kubernetes JWTAuthenticator with + support for referencing CA certificates from Kubernetes Secrets or ConfigMaps. + The operator resolves the reference at reconcile time and injects the PEM content + into the controller ConfigMap, so CA rotations are picked up automatically. properties: + certificateAuthorityConfigMap: + description: |- + CertificateAuthorityConfigMap references a Kubernetes ConfigMap containing the + CA certificate PEM for the OIDC issuer. The operator reads the specified key and + injects the PEM content as the certificateAuthority for this authenticator. + When the ConfigMap changes, the operator reconciles and updates the ConfigMap. + properties: + key: + default: ca.crt + description: |- + Key within the ConfigMap that holds the PEM-encoded CA certificate. + Defaults to "ca.crt", which is the standard key used by kube-root-ca.crt + and cert-manager CA bundles. + type: string + name: + description: Name of the ConfigMap containing the CA + certificate. + minLength: 1 + type: string + required: + - name + type: object + certificateAuthoritySecret: + description: |- + CertificateAuthoritySecret references a Kubernetes Secret containing the CA + certificate PEM for the OIDC issuer. The operator reads the specified key and + injects the PEM content as the certificateAuthority for this authenticator. + When the Secret changes, the operator reconciles and updates the ConfigMap. + Takes precedence over CertificateAuthorityConfigMap when both are set. + properties: + key: + default: tls.crt + description: |- + Key within the Secret that holds the PEM-encoded CA certificate. + Defaults to "tls.crt", which is the standard key used by cert-manager. + type: string + name: + description: Name of the Secret containing the CA certificate. + minLength: 1 + type: string + required: + - name + type: object claimMappings: description: claimMappings points claims of a token to be treated as user attributes. diff --git a/controller/deploy/operator/internal/controller/jumpstarter/ca_resolution_test.go b/controller/deploy/operator/internal/controller/jumpstarter/ca_resolution_test.go new file mode 100644 index 000000000..7a1588d42 --- /dev/null +++ b/controller/deploy/operator/internal/controller/jumpstarter/ca_resolution_test.go @@ -0,0 +1,371 @@ +/* +Copyright 2025. The Jumpstarter 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 jumpstarter + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/deploy/operator/api/v1alpha1" +) + +var _ = Describe("resolveJWTAuthenticators", func() { + var ( + ctx context.Context + testNamespace string + reconciler *JumpstarterReconciler + js *operatorv1alpha1.Jumpstarter + ) + + BeforeEach(func() { + ctx = context.Background() + + // Create a unique namespace for each test to ensure isolation. + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "ca-resolution-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + testNamespace = ns.Name + + // Seed a Secret and ConfigMap in the test namespace. + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "local-ca-secret", Namespace: testNamespace}, + Data: map[string][]byte{"tls.crt": []byte(testPEM)}, + })).To(Succeed()) + Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "local-ca-cm", Namespace: testNamespace}, + Data: map[string]string{"ca.crt": testPEM}, + })).To(Succeed()) + + reconciler = &JumpstarterReconciler{Client: k8sClient} + + js = &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-js", + Namespace: testNamespace, + }, + } + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: testNamespace}, + }) + }) + + It("passes through a JWT entry with no CA reference unchanged", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + CertificateAuthority: "inline-pem", + }, + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal("inline-pem")) + }) + + It("resolves a Secret reference in the same namespace", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + Key: "tls.crt", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("uses the default key tls.crt when Key is omitted for a Secret reference", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("resolves a ConfigMap reference in the same namespace", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "local-ca-cm", + Key: "ca.crt", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("uses the default key ca.crt when Key is omitted for a ConfigMap reference", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "local-ca-cm", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("Secret reference takes precedence over ConfigMap reference when both are set", func() { + // Update the ConfigMap with different content so the assertion is meaningful. + cm := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "local-ca-cm", Namespace: testNamespace}, cm)).To(Succeed()) + cm.Data["ca.crt"] = testPEM2 + Expect(k8sClient.Update(ctx, cm)).To(Succeed()) + + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + Key: "tls.crt", + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "local-ca-cm", + Key: "ca.crt", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + // Secret (testPEM) must win over ConfigMap (testPEM2). + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("returns an error when the referenced Secret does not exist", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "does-not-exist", + Key: "tls.crt", + }, + }, + } + + _, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("jwt[0]")) + Expect(err.Error()).To(ContainSubstring("certificateAuthoritySecret")) + Expect(err.Error()).To(ContainSubstring("does-not-exist")) + }) + + It("returns an error when the referenced key is missing from the Secret", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + Key: "wrong-key", + }, + }, + } + + _, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("jwt[0]")) + Expect(err.Error()).To(ContainSubstring(`key "wrong-key" not found`)) + }) + + It("returns an error when the referenced ConfigMap does not exist", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "missing-cm", + Key: "ca.crt", + }, + }, + } + + _, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("jwt[0]")) + Expect(err.Error()).To(ContainSubstring("certificateAuthorityConfigMap")) + Expect(err.Error()).To(ContainSubstring("missing-cm")) + }) + + It("returns an error when the referenced key is missing from the ConfigMap", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"aud"}, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "local-ca-cm", + Key: "wrong-key", + }, + }, + } + + _, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`key "wrong-key" not found`)) + }) + + It("resolves multiple JWT entries independently", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer1.example.com", + Audiences: []string{"aud1"}, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + Key: "tls.crt", + }, + }, + { + // Second entry has no CA reference — inline value should pass through unchanged. + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer2.example.com", + Audiences: []string{"aud2"}, + }, + }, + }, + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer3.example.com", + Audiences: []string{"aud3"}, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: "local-ca-cm", + Key: "ca.crt", + }, + }, + } + + result, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Issuer.CertificateAuthority).To(Equal(testPEM)) + Expect(result[1].Issuer.CertificateAuthority).To(BeEmpty()) + Expect(result[2].Issuer.CertificateAuthority).To(Equal(testPEM)) + }) + + It("returns an error on the second entry and includes the correct index", func() { + js.Spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{URL: "https://ok.example.com", Audiences: []string{"aud"}}, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "local-ca-secret", + Key: "tls.crt", + }, + }, + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{URL: "https://bad.example.com", Audiences: []string{"aud"}}, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "no-such-secret", + Key: "tls.crt", + }, + }, + } + + _, err := reconciler.resolveJWTAuthenticators(ctx, js) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("jwt[1]")) + }) +}) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index f7c8fd1b1..2e4c5eaaf 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -42,6 +42,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/yaml" @@ -163,22 +164,17 @@ func (r *JumpstarterReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } - // Reconcile ConfigMaps before deployments so we can compute the config hash - // and use it as a pod template annotation to trigger rolling restarts when config changes - if err := r.reconcileConfigMaps(ctx, &jumpstarter); err != nil { - log.Error(err, "Failed to reconcile ConfigMaps") - return ctrl.Result{}, err - } - - // Compute the configmap content hash for use as a pod template annotation. - // When the configmap content changes (e.g. OIDC auth config), the hash changes, - // which updates the pod template annotation and triggers a rolling restart of the - // controller deployment so it picks up the new configuration. - configMapHash, err := r.computeConfigMapHash(&jumpstarter) + // Build the desired controller ConfigMap once and compute its hash up front. + // The hash is embedded in the controller pod template annotation so that a config + // change (e.g. OIDC CA rotation) triggers a rolling restart without waiting for the + // next full reconcile cycle. Building here avoids calling buildConfig twice and + // guarantees the hash and the content that is later written are identical. + desiredConfigMap, err := r.createConfigMap(ctx, &jumpstarter) if err != nil { - log.Error(err, "Failed to compute configmap hash") + log.Error(err, "Failed to build controller ConfigMap") return ctrl.Result{}, err } + configMapHash := configMapDataHash(desiredConfigMap) // Reconcile Controller Deployment if err := r.reconcileControllerDeployment(ctx, &jumpstarter, configMapHash); err != nil { @@ -198,6 +194,12 @@ func (r *JumpstarterReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } + // Reconcile ConfigMaps (after deployments and services, before secrets) + if err := r.reconcileConfigMaps(ctx, &jumpstarter, desiredConfigMap); err != nil { + log.Error(err, "Failed to reconcile ConfigMaps") + return ctrl.Result{}, err + } + // Reconcile Secrets if err := r.reconcileSecrets(ctx, &jumpstarter); err != nil { log.Error(err, "Failed to reconcile Secrets") @@ -483,13 +485,12 @@ func (r *JumpstarterReconciler) reconcileServices(ctx context.Context, jumpstart return nil } -// reconcileConfigMaps reconciles all configmaps -func (r *JumpstarterReconciler) reconcileConfigMaps(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) error { +// reconcileConfigMaps reconciles all configmaps. +// desiredConfigMap is the pre-built desired state, already resolved (including any +// JWT CA Secret/ConfigMap references). Callers must build it via createConfigMap before +// calling this function so that the config hash and the written content are identical. +func (r *JumpstarterReconciler) reconcileConfigMaps(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter, desiredConfigMap *corev1.ConfigMap) error { log := logf.FromContext(ctx) - desiredConfigMap, err := r.createConfigMap(jumpstarter) - if err != nil { - return fmt.Errorf("failed to create configmap: %w", err) - } existingConfigMap := &corev1.ConfigMap{} existingConfigMap.Name = desiredConfigMap.Name @@ -637,17 +638,11 @@ func generateRandomKey(length int) (string, error) { // updateStatus is implemented in status.go -// computeConfigMapHash computes a SHA-256 hash of the configmap data for use as a pod -// template annotation. This ensures that when config changes (e.g. OIDC auth), the -// controller pods are restarted to pick up the new configuration. -func (r *JumpstarterReconciler) computeConfigMapHash(jumpstarter *operatorv1alpha1.Jumpstarter) (string, error) { - cm, err := r.createConfigMap(jumpstarter) - if err != nil { - return "", err - } - +// configMapDataHash computes a deterministic SHA-256 hash over the Data keys and values +// of a ConfigMap. Used as a pod template annotation to trigger rolling restarts when +// the controller config changes (e.g. OIDC CA rotation). +func configMapDataHash(cm *corev1.ConfigMap) string { h := sha256.New() - // Sort keys for deterministic hashing keys := make([]string, 0, len(cm.Data)) for k := range cm.Data { keys = append(keys, k) @@ -657,7 +652,7 @@ func (r *JumpstarterReconciler) computeConfigMapHash(jumpstarter *operatorv1alph h.Write([]byte(k)) h.Write([]byte(cm.Data[k])) } - return hex.EncodeToString(h.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)) } // createControllerDeployment creates a deployment for the controller @@ -1067,9 +1062,12 @@ func (r *JumpstarterReconciler) createRouterDeployment(jumpstarter *operatorv1al } // createConfigMap creates a configmap for jumpstarter configuration -func (r *JumpstarterReconciler) createConfigMap(jumpstarter *operatorv1alpha1.Jumpstarter) (*corev1.ConfigMap, error) { +func (r *JumpstarterReconciler) createConfigMap(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) (*corev1.ConfigMap, error) { // Build config struct from spec - cfg := r.buildConfig(jumpstarter) + cfg, err := r.buildConfig(ctx, jumpstarter) + if err != nil { + return nil, fmt.Errorf("failed to build config: %w", err) + } // Marshal to YAML configYAML, err := yaml.Marshal(cfg) @@ -1102,8 +1100,10 @@ func (r *JumpstarterReconciler) createConfigMap(jumpstarter *operatorv1alpha1.Ju }, nil } -// buildConfig builds the controller configuration struct from the CR spec -func (r *JumpstarterReconciler) buildConfig(jumpstarter *operatorv1alpha1.Jumpstarter) config.Config { +// buildConfig builds the controller configuration struct from the CR spec. +// It resolves any CA certificate references from Kubernetes Secrets or ConfigMaps +// and inlines the PEM content so the controller ConfigMap is self-contained. +func (r *JumpstarterReconciler) buildConfig(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) (config.Config, error) { cfg := config.Config{ Provisioning: config.Provisioning{ Enabled: jumpstarter.Spec.Authentication.AutoProvisioning.Enabled, @@ -1116,9 +1116,14 @@ func (r *JumpstarterReconciler) buildConfig(jumpstarter *operatorv1alpha1.Jumpst }, } - // Authentication configuration + // Authentication configuration — resolve any Secret/ConfigMap CA references. + resolvedJWT, err := r.resolveJWTAuthenticators(ctx, jumpstarter) + if err != nil { + return config.Config{}, err + } + auth := config.Authentication{ - JWT: jumpstarter.Spec.Authentication.JWT, + JWT: resolvedJWT, } // Internal authentication @@ -1182,7 +1187,67 @@ func (r *JumpstarterReconciler) buildConfig(jumpstarter *operatorv1alpha1.Jumpst } } - return cfg + return cfg, nil +} + +// resolveJWTAuthenticators converts the CRD-level JWTAuthenticatorConfig list into +// the standard apiserverv1beta1.JWTAuthenticator list consumed by the controller. +// For each entry that carries a certificateAuthoritySecret or certificateAuthorityConfigMap +// reference, the operator fetches the referenced resource and inlines the PEM content +// into Issuer.CertificateAuthority, overriding any value already set on the field. +func (r *JumpstarterReconciler) resolveJWTAuthenticators( + ctx context.Context, + jumpstarter *operatorv1alpha1.Jumpstarter, +) ([]apiserverv1beta1.JWTAuthenticator, error) { + log := logf.FromContext(ctx) + result := make([]apiserverv1beta1.JWTAuthenticator, 0, len(jumpstarter.Spec.Authentication.JWT)) + + for i, jwtCfg := range jumpstarter.Spec.Authentication.JWT { + authn := jwtCfg.JWTAuthenticator // copy the embedded struct + + // Secret reference takes precedence over ConfigMap reference. + switch { + case jwtCfg.CertificateAuthoritySecret != nil: + ref := jwtCfg.CertificateAuthoritySecret + key := ref.Key + if key == "" { + key = "tls.crt" + } + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: jumpstarter.Namespace}, &secret); err != nil { + return nil, fmt.Errorf("jwt[%d]: failed to fetch certificateAuthoritySecret %s/%s: %w", i, jumpstarter.Namespace, ref.Name, err) + } + pemBytes, ok := secret.Data[key] + if !ok { + return nil, fmt.Errorf("jwt[%d]: key %q not found in Secret %s/%s", i, key, jumpstarter.Namespace, ref.Name) + } + authn.Issuer.CertificateAuthority = string(pemBytes) + log.V(1).Info("Resolved CA from Secret", + "jwt_index", i, "secret", jumpstarter.Namespace+"/"+ref.Name, "key", key) + + case jwtCfg.CertificateAuthorityConfigMap != nil: + ref := jwtCfg.CertificateAuthorityConfigMap + key := ref.Key + if key == "" { + key = "ca.crt" + } + var cm corev1.ConfigMap + if err := r.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: jumpstarter.Namespace}, &cm); err != nil { + return nil, fmt.Errorf("jwt[%d]: failed to fetch certificateAuthorityConfigMap %s/%s: %w", i, jumpstarter.Namespace, ref.Name, err) + } + pemStr, ok := cm.Data[key] + if !ok { + return nil, fmt.Errorf("jwt[%d]: key %q not found in ConfigMap %s/%s", i, key, jumpstarter.Namespace, ref.Name) + } + authn.Issuer.CertificateAuthority = pemStr + log.V(1).Info("Resolved CA from ConfigMap", + "jwt_index", i, "configmap", jumpstarter.Namespace+"/"+ref.Name, "key", key) + } + + result = append(result, authn) + } + + return result, nil } // buildRouter builds the router configuration with entries for all replicas @@ -1455,8 +1520,100 @@ func defaultRouterResources(spec corev1.ResourceRequirements) corev1.ResourceReq return spec } +// Index field names used to look up Jumpstarter CRs from referenced CA resources. +const ( + // indexCASecret is the field index that maps each Jumpstarter CR to the + // "namespace/name" keys of Secrets referenced as JWT CA certificates. + indexCASecret = ".spec.authentication.jwt.certificateAuthoritySecret" + // indexCAConfigMap is the field index that maps each Jumpstarter CR to the + // "namespace/name" keys of ConfigMaps referenced as JWT CA certificates. + indexCAConfigMap = ".spec.authentication.jwt.certificateAuthorityConfigMap" +) + // SetupWithManager sets up the controller with the Manager. +// In addition to watching owned resources, it watches any Secrets and ConfigMaps +// referenced as JWT CA certificates so that CA rotations are picked up automatically. func (r *JumpstarterReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Index Jumpstarter CRs by the Secrets they reference as CA certificates. + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &operatorv1alpha1.Jumpstarter{}, + indexCASecret, + func(obj client.Object) []string { + jumpstarter := obj.(*operatorv1alpha1.Jumpstarter) + var keys []string + for _, jwtCfg := range jumpstarter.Spec.Authentication.JWT { + if ref := jwtCfg.CertificateAuthoritySecret; ref != nil { + keys = append(keys, jumpstarter.Namespace+"/"+ref.Name) + } + } + return keys + }, + ); err != nil { + return fmt.Errorf("failed to set up %s index: %w", indexCASecret, err) + } + + // Index Jumpstarter CRs by the ConfigMaps they reference as CA certificates. + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &operatorv1alpha1.Jumpstarter{}, + indexCAConfigMap, + func(obj client.Object) []string { + jumpstarter := obj.(*operatorv1alpha1.Jumpstarter) + var keys []string + for _, jwtCfg := range jumpstarter.Spec.Authentication.JWT { + if ref := jwtCfg.CertificateAuthorityConfigMap; ref != nil { + keys = append(keys, jumpstarter.Namespace+"/"+ref.Name) + } + } + return keys + }, + ); err != nil { + return fmt.Errorf("failed to set up %s index: %w", indexCAConfigMap, err) + } + + // mapSecretToJumpstarters returns a reconcile request for every Jumpstarter + // CR that references the changed Secret as a JWT CA certificate. + mapSecretToJumpstarters := func(ctx context.Context, obj client.Object) []ctrl.Request { + secret := obj.(*corev1.Secret) + key := secret.Namespace + "/" + secret.Name + + var jumpstarterList operatorv1alpha1.JumpstarterList + if err := mgr.GetClient().List(ctx, &jumpstarterList, client.MatchingFields{ + indexCASecret: key, + }); err != nil { + logf.FromContext(ctx).Error(err, "Failed to list Jumpstarters for Secret CA ref", "secret", key) + return nil + } + + requests := make([]ctrl.Request, len(jumpstarterList.Items)) + for i, js := range jumpstarterList.Items { + requests[i] = ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&js)} + } + return requests + } + + // mapConfigMapToJumpstarters returns a reconcile request for every Jumpstarter + // CR that references the changed ConfigMap as a JWT CA certificate. + mapConfigMapToJumpstarters := func(ctx context.Context, obj client.Object) []ctrl.Request { + cm := obj.(*corev1.ConfigMap) + key := cm.Namespace + "/" + cm.Name + + var jumpstarterList operatorv1alpha1.JumpstarterList + if err := mgr.GetClient().List(ctx, &jumpstarterList, client.MatchingFields{ + indexCAConfigMap: key, + }); err != nil { + logf.FromContext(ctx).Error(err, "Failed to list Jumpstarters for ConfigMap CA ref", "configmap", key) + return nil + } + + requests := make([]ctrl.Request, len(jumpstarterList.Items)) + for i, js := range jumpstarterList.Items { + requests[i] = ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&js)} + } + return requests + } + return ctrl.NewControllerManagedBy(mgr). For(&operatorv1alpha1.Jumpstarter{}). Named("jumpstarter"). @@ -1465,6 +1622,10 @@ func (r *JumpstarterReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.ConfigMap{}). Owns(&rbacv1.Role{}). Owns(&rbacv1.RoleBinding{}). - // Note: Secrets and ServiceAccounts are intentionally NOT owned to prevent deletion + // Note: Secrets and ServiceAccounts are intentionally NOT owned to prevent deletion. + // However, we do watch Secrets and ConfigMaps referenced as JWT CA certificates so + // that CA rotations are picked up automatically and the ConfigMap is updated. + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(mapSecretToJumpstarters)). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(mapConfigMapToJumpstarters)). Complete(r) } diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go index fd94bf7dc..e0819b827 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go @@ -18,6 +18,7 @@ package jumpstarter import ( "context" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -26,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" "sigs.k8s.io/controller-runtime/pkg/reconcile" operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/deploy/operator/api/v1alpha1" @@ -127,6 +129,260 @@ var _ = Describe("Jumpstarter Controller", func() { }) }) +var _ = Describe("Jumpstarter Controller — JWT CA resolution", func() { + const caSecretName = "test-ca-secret" + const caCMName = "test-ca-cm" + const caKey = "tls.crt" + const caKeyPEM = "ca.crt" + const crName = "test-jwt-ca" + + // Each test gets its own namespace so fixed-name resources like + // jumpstarter-service-ca-cert don't collide with other CRs in "default". + var crNamespace string + + ctx := context.Background() + + makeJumpstarterSpec := func() operatorv1alpha1.JumpstarterSpec { + return operatorv1alpha1.JumpstarterSpec{ + BaseDomain: "example.com", + CertManager: operatorv1alpha1.CertManagerConfig{ + Enabled: false, // cert-manager CRDs are not available in envtest + }, + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: 1, + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, + }, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: 1, + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, + }, + }, + } + } + + BeforeEach(func() { + // Create an isolated namespace for each test so fixed-name resources + // (jumpstarter-service-ca-cert, jumpstarter-controller, etc.) don't + // collide with those owned by the test-resource CR in "default". + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "jwt-ca-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + crNamespace = ns.Name + }) + + doReconcile := func() { + r := &JumpstarterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + EndpointReconciler: endpoints.NewReconciler(k8sClient, k8sClient.Scheme(), cfg), + } + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: crName, Namespace: crNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + } + + getConfigData := func() string { + cm := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "jumpstarter-controller", + Namespace: crNamespace, + }, cm) + Expect(err).NotTo(HaveOccurred()) + return cm.Data["config"] + } + + AfterEach(func() { + // Delete the namespace — this cascades to all owned resources. + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: crNamespace}, + }) + }) + + It("inlines the CA PEM from a Secret reference into the controller ConfigMap", func() { + By("creating a CA Secret") + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: caSecretName, Namespace: crNamespace}, + Data: map[string][]byte{caKey: []byte(testPEM)}, + })).To(Succeed()) + + By("creating a Jumpstarter CR with a certificateAuthoritySecret reference") + spec := makeJumpstarterSpec() + spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"jumpstarter-cli"}, + }, + ClaimMappings: apiserverv1beta1.ClaimMappings{ + Username: apiserverv1beta1.PrefixedClaimOrExpression{ + Claim: "preferred_username", + Prefix: strPtr("oidc:"), + }, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: caSecretName, + Key: caKey, + }, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + By("reconciling") + doReconcile() + + By("verifying the CA PEM is inlined in the controller ConfigMap") + Expect(getConfigData()).To(ContainSubstring("BEGIN CERTIFICATE")) + }) + + It("inlines the CA PEM from a ConfigMap reference into the controller ConfigMap", func() { + By("creating a CA ConfigMap") + Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: caCMName, Namespace: crNamespace}, + Data: map[string]string{caKeyPEM: testPEM}, + })).To(Succeed()) + + By("creating a Jumpstarter CR with a certificateAuthorityConfigMap reference") + spec := makeJumpstarterSpec() + spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"jumpstarter-cli"}, + }, + ClaimMappings: apiserverv1beta1.ClaimMappings{ + Username: apiserverv1beta1.PrefixedClaimOrExpression{ + Claim: "preferred_username", + Prefix: strPtr("oidc:"), + }, + }, + }, + CertificateAuthorityConfigMap: &operatorv1alpha1.ConfigMapKeySelector{ + Name: caCMName, + Key: caKeyPEM, + }, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + By("reconciling") + doReconcile() + + By("verifying the CA PEM is inlined in the controller ConfigMap") + Expect(getConfigData()).To(ContainSubstring("BEGIN CERTIFICATE")) + }) + + It("returns an error when the referenced Secret does not exist", func() { + By("creating a Jumpstarter CR referencing a non-existent Secret") + spec := makeJumpstarterSpec() + spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"jumpstarter-cli"}, + }, + ClaimMappings: apiserverv1beta1.ClaimMappings{ + Username: apiserverv1beta1.PrefixedClaimOrExpression{ + Claim: "preferred_username", + Prefix: strPtr("oidc:"), + }, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "no-such-secret", + Key: "tls.crt", + }, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + By("reconciling — expect error due to missing Secret") + r := &JumpstarterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + EndpointReconciler: endpoints.NewReconciler(k8sClient, k8sClient.Scheme(), cfg), + } + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: crName, Namespace: crNamespace}, + }) + Expect(err).To(HaveOccurred()) + Expect(strings.ToLower(err.Error())).To(ContainSubstring("no-such-secret")) + }) + + It("updates the ConfigMap when the CA Secret is rotated", func() { + By("creating the initial CA Secret") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: caSecretName, Namespace: crNamespace}, + Data: map[string][]byte{caKey: []byte(testPEM)}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("creating the Jumpstarter CR") + spec := makeJumpstarterSpec() + spec.Authentication.JWT = []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"jumpstarter-cli"}, + }, + ClaimMappings: apiserverv1beta1.ClaimMappings{ + Username: apiserverv1beta1.PrefixedClaimOrExpression{ + Claim: "preferred_username", + Prefix: strPtr("oidc:"), + }, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: caSecretName, + Key: caKey, + }, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + By("first reconcile — original cert") + doReconcile() + firstConfig := getConfigData() + Expect(firstConfig).To(ContainSubstring("BEGIN CERTIFICATE")) + + By("rotating the CA Secret") + secret.Data[caKey] = []byte(testPEM2) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + By("second reconcile — should pick up the rotated cert") + doReconcile() + secondConfig := getConfigData() + Expect(secondConfig).To(ContainSubstring("BEGIN CERTIFICATE")) + Expect(secondConfig).NotTo(Equal(firstConfig)) + }) +}) + +// strPtr is a helper to create a pointer to a string literal. +func strPtr(s string) *string { + return &s +} + var _ = Describe("ensurePort", func() { DescribeTable("should handle addresses correctly", func(address, defaultPort, expected string) { diff --git a/controller/deploy/operator/internal/controller/jumpstarter/testdata_test.go b/controller/deploy/operator/internal/controller/jumpstarter/testdata_test.go new file mode 100644 index 000000000..24f92c8e9 --- /dev/null +++ b/controller/deploy/operator/internal/controller/jumpstarter/testdata_test.go @@ -0,0 +1,41 @@ +/* +Copyright 2025. The Jumpstarter 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 jumpstarter + +// testPEM and testPEM2 are fake PEM certificates shared across multiple test +// files in this package (ca_resolution_test.go and jumpstarter_controller_test.go). +const testPEM = `-----BEGIN CERTIFICATE----- +MIIBpDCCAQmgAwIBAgIUTest1234Test1234Test1234Test1234wCgYIKoZIzj0E +AwIwETEPMA0GA1UEAxMGdGVzdENBMB4XDTI1MDEwMTAwMDAwMFoXDTI2MDEwMTAw +MDAwMFowETEPMA0GA1UEAxMGdGVzdENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAo0IwQDAdBgNVHQ4EFgQUTest12 +34Test1234Test1234Test1234wDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB +Af8wCgYIKoZIzj0EAwIDaAAwZQIwTest1234Test1234Test1234Test1234Test12 +34Test1234Test1234Test1234Test1234Test1234Test1234Test1234Test1234== +-----END CERTIFICATE----- +` + +const testPEM2 = `-----BEGIN CERTIFICATE----- +MIIBpDCCAQmgAwIBAgIURotated5678Rotated5678Rotated5678Rotated5678w +CgYIKoZIzj0EAwIwFDESMBAGA1UEAxMJcm90YXRlZENBMB4XDTI1MDEwMTAwMDAw +MFoXDTI2MDEwMTAwMDAwMFowFDESMBAGA1UEAxMJcm90YXRlZENBMHYwEAYHKoZI +zj0CAQYFK4EEACIDYgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArotated== +-----END CERTIFICATE----- +` diff --git a/controller/deploy/operator/test/e2e/e2e_test.go b/controller/deploy/operator/test/e2e/e2e_test.go index 8e581f2e4..3f4f76227 100644 --- a/controller/deploy/operator/test/e2e/e2e_test.go +++ b/controller/deploy/operator/test/e2e/e2e_test.go @@ -41,6 +41,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/yaml" + apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1" @@ -1925,6 +1926,167 @@ spec: _ = deleteSelfSignedClusterIssuer(clusterIssuerName) }) }) + + Context("JWT CA Secret/ConfigMap reference", Ordered, func() { + var jwtCATestNamespace string + + const fakePEM = `-----BEGIN CERTIFICATE----- +MIIBpDCCAQmgAwIBAgIUFakeCAForE2ETest1234567890FakeCAForE2ETest12wCg +YIKoZIzj0EAwIwETEPMA0GA1UEAxMGdGVzdENBMB4XDTI1MDEwMTAwMDAwMFoXDTI2 +MDEwMTAwMDAwMFowETEPMA0GA1UEAxMGdGVzdENBMHYwEAYHKoZIzj0CAQYFK4EE +ACIDYgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAo0IwQDAdBgNVHQ4EFgQU +FakeCAForE2ETest12340DgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDaAAwZQIwFakeSignatureFakeSignatureFakeSignatureFake +SignatureFakeSignatureFakeSignatureFakeSignatureFakeSignatureFakeSig== +-----END CERTIFICATE----- +` + + const rotatedPEM = `-----BEGIN CERTIFICATE----- +MIIBpDCCAQmgAwIBAgIURotatedCAForE2ETest1234567890RotatedCAForE2wCg +YIKoZIzj0EAwIwGDEWMBQGA1UEAxMNcm90YXRlZC10ZXN0Q0EwHhcNMjUwMTAxMDAw +MDAwWhcNMjYwMTAxMDAwMDAwWjAYMRYwFAYDVQQDEw1yb3RhdGVkLXRlc3RDQTB2MB +AGByqGSM49AgEGBSuBBAAiA2IABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAo0Iw +QDAdBgNVHQ4EFgQURotatedCAForE2ETest1234560DgYDVR0PAQH/BAQDAgGGMA8G +A1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDaAAwZQIwRotatedSignatureRotate +dSignatureRotatedSignatureRotatedSignatureRotatedSignatureRotatedSig== +-----END CERTIFICATE----- +` + + strPtr := func(s string) *string { return &s } + + BeforeAll(func() { + jwtCATestNamespace = CreateTestNamespace() + }) + + AfterAll(func() { + DeleteTestNamespace(jwtCATestNamespace) + }) + + It("should inline CA PEM from a Secret reference into the controller ConfigMap", func() { + image := os.Getenv("IMG") + if image == "" { + image = defaultControllerImage + } + + By("creating a CA Secret in the test namespace") + caSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "jwt-ca-secret", + Namespace: jwtCATestNamespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte(fakePEM), + }, + } + Expect(k8sClient.Create(ctx, caSecret)).To(Succeed()) + + By("creating a Jumpstarter CR with certificateAuthoritySecret") + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "jwt-ca-test", + Namespace: jwtCATestNamespace, + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + BaseDomain: "apps-crc.testing", + Controller: operatorv1alpha1.ControllerConfig{ + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: 1, + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{ + {Address: fmt.Sprintf("grpc.%s:8082", jwtCATestNamespace)}, + }, + }, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: 1, + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{ + {Address: fmt.Sprintf("router.%s:8083", jwtCATestNamespace)}, + }, + }, + }, + Authentication: operatorv1alpha1.AuthenticationConfig{ + Internal: operatorv1alpha1.InternalAuthConfig{Enabled: true}, + JWT: []operatorv1alpha1.JWTAuthenticatorConfig{ + { + JWTAuthenticator: apiserverv1beta1.JWTAuthenticator{ + Issuer: apiserverv1beta1.Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"jumpstarter-cli"}, + }, + ClaimMappings: apiserverv1beta1.ClaimMappings{ + Username: apiserverv1beta1.PrefixedClaimOrExpression{ + Claim: "preferred_username", + Prefix: strPtr("oidc:"), + }, + }, + }, + CertificateAuthoritySecret: &operatorv1alpha1.SecretKeySelector{ + Name: "jwt-ca-secret", + Key: "tls.crt", + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, js)).To(Succeed()) + + By("waiting for the controller ConfigMap to contain the inlined CA PEM") + Eventually(func(g Gomega) { + cm := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "jumpstarter-controller", + Namespace: jwtCATestNamespace, + }, cm) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cm.Data).To(HaveKey("config")) + g.Expect(cm.Data["config"]).To(ContainSubstring("FakeCAForE2ETest")) + }, 2*time.Minute).Should(Succeed()) + }) + + It("should update the controller ConfigMap when the CA Secret is rotated", func() { + By("reading the current ConfigMap content") + cm := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "jumpstarter-controller", + Namespace: jwtCATestNamespace, + }, cm)).To(Succeed()) + originalConfig := cm.Data["config"] + + By("rotating the CA Secret") + secret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "jwt-ca-secret", + Namespace: jwtCATestNamespace, + }, secret)).To(Succeed()) + secret.Data["tls.crt"] = []byte(rotatedPEM) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + By("waiting for the controller ConfigMap to contain the rotated CA PEM") + Eventually(func(g Gomega) { + updatedCM := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "jumpstarter-controller", + Namespace: jwtCATestNamespace, + }, updatedCM) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(updatedCM.Data["config"]).NotTo(Equal(originalConfig), + "ConfigMap should have been updated after CA rotation") + // Use a unique marker from inside the rotated PEM (single line) rather than + // the full multi-line block — YAML block scalars indent every line so a + // full-block ContainSubstring won't match. + g.Expect(updatedCM.Data["config"]).NotTo(ContainSubstring("FakeCAForE2ETest"), + "Old CA content should be gone after rotation") + g.Expect(updatedCM.Data["config"]).To(ContainSubstring("RotatedCAForE2ETest")) + }, 2*time.Minute).Should(Succeed()) + }) + }) }) // serviceAccountToken returns a token for the specified service account in the given namespace. diff --git a/typos.toml b/typos.toml index b45e23c5d..80687cd30 100644 --- a/typos.toml +++ b/typos.toml @@ -15,6 +15,10 @@ ANDed = "ANDed" Ded = "Ded" # suffix of ANDed in generated CRD docs ORed = "ORed" +# UE and Fo appear as substrings inside base64-encoded certificate data in tests +UE = "UE" +Fo = "Fo" + # mosquitto is the name of an MQTT broker, not a typo of "mosquito" mosquitto = "mosquitto"