From b9dc4bee59a68dd1afa14b102c1dd5d313b5840c Mon Sep 17 00:00:00 2001 From: Sven Rajala Date: Thu, 18 Dec 2025 11:00:08 -0500 Subject: [PATCH 01/14] feat: Add volume and volume mount for service token --- .../templates/deployment.yaml | 16 ++++++++++++++++ .../templates/serviceaccount.yaml | 1 + .../command-cert-manager-issuer/values.yaml | 9 +++++++++ 3 files changed, 26 insertions(+) diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index 34e3bd1..e9b9e80 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -28,6 +28,16 @@ spec: serviceAccountName: {{ include "command-cert-manager-issuer.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if not .Values.serviceAccount.automountServiceAccountToken }} + volumes: + - name: serviceaccount-token + projected: + defaultMode: {{ .Values.serviceAccount.projectedTokenVolume.defaultMode }} + sources: + - serviceAccountToken: + expirationSeconds: {{ .Values.serviceAccount.projectedTokenVolume.expirationSeconds }} + path: token + {{- end }} containers: - args: - --health-probe-bind-address=:8081 @@ -67,6 +77,12 @@ spec: {{- toYaml .Values.resources | nindent 12 }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + {{- if not .Values.serviceAccount.automountServiceAccountToken }} + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: serviceaccount-token + readOnly: true + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml b/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml index 948bf2a..a5bab21 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml @@ -13,4 +13,5 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} {{- end }} diff --git a/deploy/charts/command-cert-manager-issuer/values.yaml b/deploy/charts/command-cert-manager-issuer/values.yaml index c63b5c6..e715b4c 100644 --- a/deploy/charts/command-cert-manager-issuer/values.yaml +++ b/deploy/charts/command-cert-manager-issuer/values.yaml @@ -48,6 +48,15 @@ serviceAccount: # The name of the service account to use. # If not set and create is true, a name is generated using the fullname template name: "" + # Specifies whether to automount the service account token + # If false, a projected volume will be used to mount the token + automountServiceAccountToken: false + # Configuration for projected service account token volume (used when automountServiceAccountToken is false) + projectedTokenVolume: + # Token expiration time in seconds + expirationSeconds: 3607 + # File permissions for the token + defaultMode: 0444 podLabels: {} From 51c8ff584d4de292872ae492186ebc8cb209cfd5 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 19 May 2026 16:19:30 -0400 Subject: [PATCH 02/14] chore: fix sources and add changelog Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 19 +++++++++++++++++++ .../templates/deployment.yaml | 11 +++++++++++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e444c..5c2836d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# v2.6.0 +TODO -- add notes for other changes + +## Security +- The Helm chart now defaults `serviceAccount.automountServiceAccountToken` to `false`, + replacing the long-lived auto-mounted token with a short-lived projected token (~1 hour, automatically rotated by kubelet). The token is still mounted at the standard path `/var/run/secrets/kubernetes.io/serviceaccount` so no application changes are required. + +> [!IMPORTANT] +> +> ### Upgrade Notes +> +> - **Rolling restart**: Upgrading from v2.5.x will patch the ServiceAccount and update the Deployment spec, triggering an automatic rolling restart. Plan accordingly if downtime is a concern in your environment. +> - **Bring-your-own ServiceAccount**: If you set `serviceAccount.create: false` and manage your own ServiceAccount, you must either set `automountServiceAccountToken: true` in your `values.yaml` to preserve the previous behavior, or manually add `automountServiceAccountToken: false` and the projected volume to your ServiceAccount and Deployment manifests. +> - To restore the previous behavior explicitly, set in your `values.yaml`: +> ```yaml +> serviceAccount: +> automountServiceAccountToken: true +> ``` + # v2.5.2 ## Fixes - Fixes an issue where a namespace may not be properly applied if applying the Helm template without a namespace specified / using `kubectl apply -f` directly with the rendered template. diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index e9b9e80..74018fd 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -37,6 +37,17 @@ spec: - serviceAccountToken: expirationSeconds: {{ .Values.serviceAccount.projectedTokenVolume.expirationSeconds }} path: token + - configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: ca.crt + - downwardAPI: + items: + - path: namespace + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace {{- end }} containers: - args: From 07e8d41410cfae873061f153952c4893ff2f5251 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 20 May 2026 12:34:51 -0400 Subject: [PATCH 03/14] feat(enrollment): make certificateAuthorityLogicalName be optional when using enrollment patterns Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 3 +- api/v1alpha1/issuer_types.go | 5 +- ...d-issuer.keyfactor.com_clusterissuers.yaml | 4 +- .../command-issuer.keyfactor.com_issuers.yaml | 4 +- .../templates/crds/clusterissuers.yaml | 4 +- .../templates/crds/issuers.yaml | 4 +- docsource/content.md | 37 +- internal/command/command.go | 23 +- internal/command/command_test.go | 17 +- internal/controller/issuer_controller_test.go | 666 ------------------ 10 files changed, 74 insertions(+), 693 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2836d..7083b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # v2.6.0 -TODO -- add notes for other changes +## Features +- Allow `certificateAuthorityLogicalName` to be optional when using an enrollment pattern. ## Security - The Helm chart now defaults `serviceAccount.automountServiceAccountToken` to `false`, diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index deba1d6..73deea4 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -85,8 +85,11 @@ type IssuerSpec struct { // + optional OwnerRoleName string `json:"ownerRoleName,omitempty"` - // CertificateAuthorityLogicalName is the logical name of the certificate authority to use + // CertificateAuthorityLogicalName is the logical name of the certificate authority to use. Not required if an enrollment pattern is specified, + // except if the enrollment pattern targets a standalone CA. If empty, an eligible certificate authority within the enrollment pattern's configuration tenant + // will be used. // E.g. "Keyfactor Root CA" or "Intermediate CA" + // +optional CertificateAuthorityLogicalName string `json:"certificateAuthorityLogicalName,omitempty"` // CertificateAuthorityHostname is the hostname associated with the Certificate Authority specified by diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 33f2b32..55fcf5b 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -79,7 +79,9 @@ spec: type: string certificateAuthorityLogicalName: description: |- - CertificateAuthorityLogicalName is the logical name of the certificate authority to use + CertificateAuthorityLogicalName is the logical name of the certificate authority to use. Not required if an enrollment pattern is specified, + except if the enrollment pattern targets a standalone CA. If empty, an eligible certificate authority within the enrollment pattern's configuration tenant + will be used. E.g. "Keyfactor Root CA" or "Intermediate CA" type: string certificateTemplate: diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 27db089..ceef0ce 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -79,7 +79,9 @@ spec: type: string certificateAuthorityLogicalName: description: |- - CertificateAuthorityLogicalName is the logical name of the certificate authority to use + CertificateAuthorityLogicalName is the logical name of the certificate authority to use. Not required if an enrollment pattern is specified, + except if the enrollment pattern targets a standalone CA. If empty, an eligible certificate authority within the enrollment pattern's configuration tenant + will be used. E.g. "Keyfactor Root CA" or "Intermediate CA" type: string certificateTemplate: diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 4206341..2328418 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -73,7 +73,9 @@ spec: type: string certificateAuthorityLogicalName: description: |- - CertificateAuthorityLogicalName is the logical name of the certificate authority to use + CertificateAuthorityLogicalName is the logical name of the certificate authority to use. Not required if an enrollment pattern is specified, + except if the enrollment pattern targets a standalone CA. If empty, an eligible certificate authority within the enrollment pattern's configuration tenant + will be used. E.g. "Keyfactor Root CA" or "Intermediate CA" type: string enrollmentPatternId: diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index efb2dea..f3d5387 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -73,7 +73,9 @@ spec: type: string certificateAuthorityLogicalName: description: |- - CertificateAuthorityLogicalName is the logical name of the certificate authority to use + CertificateAuthorityLogicalName is the logical name of the certificate authority to use. Not required if an enrollment pattern is specified, + except if the enrollment pattern targets a standalone CA. If empty, an eligible certificate authority within the enrollment pattern's configuration tenant + will be used. E.g. "Keyfactor Root CA" or "Intermediate CA" type: string enrollmentPatternId: diff --git a/docsource/content.md b/docsource/content.md index 99cd071..9f6c983 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -9,10 +9,10 @@ Before continuing, ensure that the following requirements are met: - [Keyfactor Command](https://www.keyfactor.com/products/command/) >= 10.5 - Command must be properly configured according to the [product docs](https://software.keyfactor.com/Core-OnPrem/Current/Content/MasterTopics/Portal.htm). - You have access to the Command REST API. The following endpoints must be available: - - `/Status/Endpoints` - - `/Enrollment/CSR` - - `/MetadataFields` - - `/EnrollmentPatterns` (Keyfactor Command 25.1 and above) + - [/Status/Endpoints](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/StatusGetEndpoints.htm) + - [/Enrollment/CSR](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/EnrollmentPOSTCSR.htm) + - [/MetadataFields](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/MetadataFieldsGet.htm) + - [/EnrollmentPatterns](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/Enrollment-Patterns-GET.htm) (Keyfactor Command 25.1 and above) - Kubernetes >= v1.19 - [Kubernetes](https://kubernetes.io/docs/tasks/tools/), [Minikube](https://minikube.sigs.k8s.io/docs/start/), [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/), etc. > You must have permission to create [Custom Resource Definitions](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/) in your Kubernetes cluster. @@ -226,7 +226,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | caBundleConfigMapName | (optional) The name of the Kubernetes ConfigMap containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | caBundleKey | (optional) The name of the key in the ConfigMap or Secret specified by `caSecretName` or `caBundleConfigMapName` that contains the CA bundle. If omitted, the last key of the ConfigMap / Secret resource will be used. | - | certificateAuthorityLogicalName | The logical name of the Certificate Authority to use in Command. For example, `Sub-CA` | + | certificateAuthorityLogicalName | (Optional) The logical name of the Certificate Authority to use in Command. For example, `Sub-CA`. Optional if the enrollment pattern does not target a standalone CA. When not defined, Command will choose an eligible CA within the enrollment pattern's configuration tenant. | | certificateAuthorityHostname | (optional) The hostname of the Certificate Authority specified by `certificateAuthorityLogicalName`. This field is usually only required if the CA in Command is a DCOM (MSCA-like) CA. | | enrollmentPatternId | The ID of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternId` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. | | enrollmentPatternName | The Name of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternName` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. If using `enrollmentPatternName`, your security role must have `/enrollment_pattern/read/` permission. | @@ -241,6 +241,17 @@ For example, ClusterIssuer resources can be used to issue certificates for resou > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. + > **What is a standalone CA?** + > A standalone CA is a Certificate Authority in Keyfactor Command that is not configured + > as part of a CA pool within the enrollment pattern — typically a DCOM (Microsoft CA) + > configured as a standalone (non-Active-Directory-integrated) CA. When an enrollment + > pattern targets a standalone CA, Command cannot automatically select a CA from a pool + > and requires `certificateAuthorityLogicalName` to be explicitly provided. + > + > If you are unsure whether your CA is a standalone CA, check the CA type and configuration + > in Command under **Certificate Authorities**, or contact your Keyfactor support + > representative. + 2. **Create an Issuer or ClusterIssuer** - **Issuer** @@ -263,10 +274,10 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" - enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. - # enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required @@ -298,10 +309,10 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" - enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. - # enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required diff --git a/internal/command/command.go b/internal/command/command.go index a88a3d5..6d74914 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -260,9 +260,11 @@ func (s *SignConfig) validate() error { if s.CertificateTemplate == "" && s.EnrollmentPatternName == "" && s.EnrollmentPatternId == 0 { return errors.New("either certificateTemplate, enrollmentPatternName, or enrollmentPatternId must be specified") } - if s.CertificateAuthorityLogicalName == "" { - return errors.New("certificateAuthorityLogicalName is required") + + if !s.IsCertificateAuthorityDefined() && !s.IsEnrollmentPatternDefined() { + return errors.New("certificateAuthorityLogicalName is required if enrollmentPatternName or enrollmentPatternId are not provided") } + return nil } @@ -373,6 +375,15 @@ func (s *signer) Sign(ctx context.Context, csrBytes []byte, config *SignConfig) return nil, nil, err } + // If certificate authority is not defined alongside the enrollment pattern, Command will choose + // a CA within the same configuration tenant that best suits the certificate template, unless + // the target is a standalone CA. + // + // https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/EnrollmentPOSTCSR.htm + if config.IsEnrollmentPatternDefined() && !config.IsCertificateAuthorityDefined() { + k8sLog.Info("certificateAuthorityLogicalName is not set; the Command API may require a certificate authority if the enrollment pattern targets a standalone CA") + } + request, model, caBuilder, err := s.buildCsrEnrollRequest(config, k8sLog, csrBytes) if err != nil { return nil, nil, err @@ -689,3 +700,11 @@ func getEnrollmentPatternByName(log logr.Logger, s *signer, enrollmentPatternNam return model, nil } + +func (s *SignConfig) IsEnrollmentPatternDefined() bool { + return s.EnrollmentPatternId != 0 || s.EnrollmentPatternName != "" +} + +func (s *SignConfig) IsCertificateAuthorityDefined() bool { + return s.CertificateAuthorityLogicalName != "" +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go index ac0faf5..239ae47 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2025 Keyfactor +Copyright © 2026 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -208,9 +208,14 @@ func TestSignConfigValidate(t *testing.T) { wantErr: "either certificateTemplate, enrollmentPatternName, or enrollmentPatternId must be specified", }, { - name: "missing certificateAuthorityLogicalName", - config: &SignConfig{CertificateTemplate: "myTemplate", CertificateAuthorityLogicalName: "", CertificateAuthorityHostname: "ca.example.com"}, - wantErr: "certificateAuthorityLogicalName is required", + name: "missing certificateAuthorityLogicalName and enrollmentPatternName", + config: &SignConfig{CertificateTemplate: "myTemplate", CertificateAuthorityLogicalName: "", CertificateAuthorityHostname: "ca.example.com", EnrollmentPatternName: ""}, + wantErr: "certificateAuthorityLogicalName is required if enrollmentPatternName or enrollmentPatternId are not provided", + }, + { + name: "missing certificateAuthorityLogicalName and enrollmentPatternId", + config: &SignConfig{CertificateTemplate: "myTemplate", CertificateAuthorityLogicalName: "", CertificateAuthorityHostname: "ca.example.com", EnrollmentPatternId: 0}, + wantErr: "certificateAuthorityLogicalName is required if enrollmentPatternName or enrollmentPatternId are not provided", }, { name: "all valid fields (both certificateTemplate and enrollmentPatternName specified)", @@ -224,12 +229,12 @@ func TestSignConfigValidate(t *testing.T) { }, { name: "all valid fields (only enrollmentPatternName specified)", - config: &SignConfig{EnrollmentPatternName: "My Enrollment Pattern", CertificateAuthorityLogicalName: "ca-logical", CertificateAuthorityHostname: "ca.example.com"}, + config: &SignConfig{EnrollmentPatternName: "My Enrollment Pattern", CertificateAuthorityLogicalName: "", CertificateAuthorityHostname: "ca.example.com"}, wantErr: "", }, { name: "all valid fields (only enrollmentPatternId specified)", - config: &SignConfig{EnrollmentPatternId: 123, CertificateAuthorityLogicalName: "ca-logical", CertificateAuthorityHostname: "ca.example.com"}, + config: &SignConfig{EnrollmentPatternId: 123, CertificateAuthorityLogicalName: "", CertificateAuthorityHostname: "ca.example.com"}, wantErr: "", }, { diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 4a2c4ce..54fda8b 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -1621,669 +1621,3 @@ func TestCommandConfigFromIssuer(t *testing.T) { }) } } - -func TestCommandConfigFromIssuer(t *testing.T) { - type testCase struct { - name string - issuerSpec commandissuerv1alpha1.IssuerSpec - secretNamespace string - objects []client.Object - expectedConfig *command.Config - expectedError error - expectedErrorMsg string - } - - tests := []testCase{ - { - name: "success-basic-auth", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-basic-auth-with-ca-cert-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - CaSecretName: "ca-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-basic-auth-with-ca-cert-secret-with-key", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - CaSecretName: "ca-secret", - CaBundleKey: "ca.crt", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-basic-auth-with-ca-cert-configmap", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - CaBundleConfigMapName: "ca-configmap", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-configmap", - Namespace: "ns1", - }, - Data: map[string]string{ - "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-basic-auth-with-ca-cert-configmap-with-key", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - CaBundleConfigMapName: "ca-configmap", - CaBundleKey: "ca.crt", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-configmap", - Namespace: "ns1", - }, - Data: map[string]string{ - "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", - "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-basic-auth-with-ca-cert-configmap-overwrites-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "auth-secret", - CaSecretName: "ca-secret", - CaBundleConfigMapName: "ca-configmap", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - "ca.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - }, - }, - &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-configmap", - Namespace: "ns1", - }, - Data: map[string]string{ - "ca.crt": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - CaCertsBytes: []byte("-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"), - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-oauth-minimal", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "oauth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "oauth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), - commandissuer.OAuthClientIDKey: []byte("client-id"), - commandissuer.OAuthClientSecretKey: []byte("client-secret"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - OAuth: &command.OAuth{ - TokenURL: "https://oauth.example.com/token", - ClientID: "client-id", - ClientSecret: "client-secret", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-oauth-with-scopes-and-audience", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - SecretName: "oauth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "oauth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), - commandissuer.OAuthClientIDKey: []byte("client-id"), - commandissuer.OAuthClientSecretKey: []byte("client-secret"), - commandissuer.OAuthScopesKey: []byte("scope1,scope2,scope3"), - commandissuer.OAuthAudienceKey: []byte("https://api.example.com"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - OAuth: &command.OAuth{ - TokenURL: "https://oauth.example.com/token", - ClientID: "client-id", - ClientSecret: "client-secret", - Scopes: []string{"scope1", "scope2", "scope3"}, - Audience: "https://api.example.com", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "success-ambient-credentials-with-scopes", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - Scopes: "scope1,scope2", - Audience: "https://api.example.com", - }, - secretNamespace: "ns1", - objects: []client.Object{}, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - AmbientCredentialScopes: []string{"scope1", "scope2"}, - AmbientCredentialAudience: "https://api.example.com", - }, - }, - { - name: "success-no-auth-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - }, - secretNamespace: "ns1", - objects: []client.Object{}, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - APIPath: "/api/v1", - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - { - name: "error-auth-secret-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "missing-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{}, - expectedError: errGetAuthSecret, - }, - { - name: "error-ca-secret-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - CaSecretName: "missing-ca-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{}, - expectedError: errGetCaSecret, - }, - { - name: "error-ca-secret-key-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - CaSecretName: "ca-secret", - CaBundleKey: "ca.crt", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----"), - }, - }, - }, - expectedError: errGetCaBundleKey, - }, - { - name: "error-ca-configmap-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - CaBundleConfigMapName: "missing-ca-bundle", - }, - secretNamespace: "ns1", - objects: []client.Object{}, - expectedError: errGetCaConfigMap, - }, - { - name: "error-ca-configmap-key-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - CaBundleConfigMapName: "ca-configmap", - CaBundleKey: "ca.crt", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-configmap", - Namespace: "ns1", - }, - Data: map[string]string{ - "tls.crt": "-----BEGIN CERTIFICATE-----\nABCD...\n-----END CERTIFICATE-----", - }, - }, - }, - expectedError: errGetCaBundleKey, - }, - { - name: "error-basic-auth-no-username", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "auth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found basic auth secret with no username", - }, - { - name: "error-basic-auth-no-password", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "auth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found basic auth secret with no password", - }, - { - name: "error-oauth-no-token-url", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "oauth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "oauth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - commandissuer.OAuthClientIDKey: []byte("client-id"), - commandissuer.OAuthClientSecretKey: []byte("client-secret"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found secret with no tokenUrl", - }, - { - name: "error-oauth-no-client-id", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "oauth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "oauth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), - commandissuer.OAuthClientSecretKey: []byte("client-secret"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found secret with no clientId", - }, - { - name: "error-oauth-no-client-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "oauth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeOpaque, - ObjectMeta: metav1.ObjectMeta{ - Name: "oauth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - commandissuer.OAuthTokenURLKey: []byte("https://oauth.example.com/token"), - commandissuer.OAuthClientIDKey: []byte("client-id"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found secret with no clientSecret", - }, - { - name: "error-unsupported-secret-type", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "auth-secret", - }, - secretNamespace: "ns1", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeTLS, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "ns1", - }, - Data: map[string][]byte{ - "tls.crt": []byte("cert"), - "tls.key": []byte("key"), - }, - }, - }, - expectedError: errGetAuthSecret, - expectedErrorMsg: "found secret with unsupported type", - }, - { - name: "success-cluster-scoped-secret-namespace", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ - Hostname: "https://ca.example.com", - SecretName: "auth-secret", - }, - secretNamespace: "kube-system", - objects: []client.Object{ - &corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - ObjectMeta: metav1.ObjectMeta{ - Name: "auth-secret", - Namespace: "kube-system", - }, - Data: map[string][]byte{ - corev1.BasicAuthUsernameKey: []byte("username"), - corev1.BasicAuthPasswordKey: []byte("password"), - }, - }, - }, - expectedConfig: &command.Config{ - Hostname: "https://ca.example.com", - BasicAuth: &command.BasicAuth{ - Username: "username", - Password: "password", - }, - AmbientCredentialScopes: []string{""}, - AmbientCredentialAudience: "", - }, - }, - } - - scheme := runtime.NewScheme() - require.NoError(t, commandissuerv1alpha1.AddToScheme(scheme)) - require.NoError(t, corev1.AddToScheme(scheme)) - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(tc.objects...). - Build() - - // Create a minimal issuer with the test spec - issuer := &commandissuerv1alpha1.Issuer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-issuer", - Namespace: tc.secretNamespace, - }, - Spec: tc.issuerSpec, - } - - ctx := context.Background() - config, err := commandConfigFromIssuer(ctx, fakeClient, issuer, tc.secretNamespace) - - if tc.expectedError != nil { - require.Error(t, err) - assert.ErrorIs(t, err, tc.expectedError) - if tc.expectedErrorMsg != "" { - assert.Contains(t, err.Error(), tc.expectedErrorMsg) - } - assert.Nil(t, config) - } else { - require.NoError(t, err) - require.NotNil(t, config) - assert.Equal(t, tc.expectedConfig.Hostname, config.Hostname) - assert.Equal(t, tc.expectedConfig.APIPath, config.APIPath) - assert.Equal(t, tc.expectedConfig.CaCertsBytes, config.CaCertsBytes) - assert.Equal(t, tc.expectedConfig.BasicAuth, config.BasicAuth) - assert.Equal(t, tc.expectedConfig.OAuth, config.OAuth) - assert.Equal(t, tc.expectedConfig.AmbientCredentialScopes, config.AmbientCredentialScopes) - assert.Equal(t, tc.expectedConfig.AmbientCredentialAudience, config.AmbientCredentialAudience) - } - }) - } -} From d7182a93488d9d9bd357f6fbf2f8489352979b2a Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 20 May 2026 17:29:33 -0400 Subject: [PATCH 04/14] updated error messaging on csr enrollment Signed-off-by: Matthew H. Irby --- internal/command/command.go | 46 ++++++----- internal/command/command_test.go | 133 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 22 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index 6d74914..2af71e4 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -409,39 +409,41 @@ func (s *signer) Sign(ctx context.Context, csrBytes []byte, config *SignConfig) commandCsrResponseObject, httpResp, err := s.client.EnrollCSR(req) if err != nil { - detail := fmt.Sprintf("error enrolling certificate with Command. Verify that the certificate authority %q (%s) is configured correctly", config.CertificateAuthorityLogicalName, config.CertificateAuthorityHostname) - - if template != nil { - detail += fmt.Sprintf(" and that the certificate template %q exists in Command", *template) - } - - if enrollmentPatternId != nil { - detail += fmt.Sprintf(". Make sure enrollment pattern ID %d is configured to use certificate authority %q (%s) and the security role is configured to use this enrollment pattern.", *enrollmentPatternId, config.CertificateAuthorityLogicalName, config.CertificateAuthorityHostname) + hasPattern := enrollmentPatternId != nil + hasCA := config.CertificateAuthorityLogicalName != "" + hasTemplate := template != nil + + var hints []string + + switch { + case hasPattern && !hasCA: + hints = append(hints, fmt.Sprintf("Ensure that enrollment pattern ID %d has CSR Enrollment enabled and the security role is configured to use this enrollment pattern.", loggedEnrollmentPatternId)) + case hasPattern && hasCA && !hasTemplate: + hints = append(hints, fmt.Sprintf("Ensure that enrollment pattern ID %d has CSR Enrollment enabled, is configured to use certificate authority '%s', and the security role is configured to use this enrollment pattern.", loggedEnrollmentPatternId, config.CertificateAuthorityLogicalName)) + case hasPattern && hasCA && hasTemplate: + hints = append(hints, fmt.Sprintf("Verify that certificate template '%s' exists in Command and belongs to certificate authority '%s'. Verify the configuration for Enrollment Pattern ID %d in Keyfactor Command, and ensure CSR Enrollment is enabled.", *template, config.CertificateAuthorityLogicalName, loggedEnrollmentPatternId)) + case !hasPattern && hasCA: + hints = append(hints, fmt.Sprintf("Verify that certificate template '%s' exists in Command and belongs to certificate authority '%s'. If applicable, verify the Enrollment Pattern configuration in Keyfactor Command, and ensure CSR Enrollment is enabled.", *template, config.CertificateAuthorityLogicalName)) } if certificateOwnerId != nil || certificateOwnerName != nil { - detail += ". Make sure the cert manager issuer's security context is assigned to the owner role name or ID." + hints = append(hints, "Verify the issuer's security role is assigned to the configured owner role.") } if len(extractMetadataFromAnnotations(config.Annotations)) > 0 { - detail += ". Also verify that the metadata fields provided exist in Command" + hints = append(hints, "Verify that the metadata fields provided exist in Command.") } - if httpResp != nil { - if httpResp.Body != nil { - defer httpResp.Body.Close() - - bodyBytes, err := io.ReadAll(httpResp.Body) - if err == nil { - detail += fmt.Sprintf(". Error response from EnrollCSR API call: %s", string(bodyBytes)) - } else { - k8sLog.Error(err, "failed to read response body from failed EnrollCSR API call") - } + if httpResp != nil && httpResp.Body != nil { + defer httpResp.Body.Close() + if bodyBytes, readErr := io.ReadAll(httpResp.Body); readErr == nil && len(bodyBytes) > 0 { + hints = append(hints, fmt.Sprintf("Error response from Command: %s", string(bodyBytes))) + } else if readErr != nil { + k8sLog.Error(readErr, "failed to read response body from failed EnrollCSR API call") } } - err = fmt.Errorf("%w: %s: %w", errCommandEnrollmentFailure, detail, err) - return nil, nil, err + return nil, nil, fmt.Errorf("%w: %s: %w", errCommandEnrollmentFailure, strings.Join(hints, " "), err) } var certBytes []byte diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 239ae47..99f8ac4 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -654,6 +654,139 @@ func TestSign(t *testing.T) { } } +func TestSign_ErrorMessages(t *testing.T) { + caCert, rootKey := issueTestCertificate(t, "Root-CA", nil, nil) + issuingCert, issuingKey := issueTestCertificate(t, "Sub-CA", caCert, rootKey) + leafCert, _ := issueTestCertificate(t, "LeafCert", issuingCert, issuingKey) + + expectedLeafAndChain := append([]*x509.Certificate{leafCert}, issuingCert) + + // enrollmentPatternName := "fake-enrollment-pattern" + certificateTemplateName := "fake-cert-template" + certificateAuthorityLogicalName := "fake-issuing-ca" + enrollmentPatternId := 123 + + testCases := map[string]struct { + enrollCSRFunctionError error + enrollHTTPResponse *http.Response + enrollmentPatterns []v1.EnrollmentPatternsEnrollmentPatternResponse + + // Request + config *SignConfig + + // Expected + expectedSignError error + expectedErrorContent *string + }{ + "enroll-csr-err-enrollment-pattern": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + EnrollmentPatternId: int32(enrollmentPatternId), + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr(fmt.Sprintf("Ensure that enrollment pattern ID %d has CSR Enrollment enabled and the security role is configured to use this enrollment pattern.", enrollmentPatternId)), + }, + "enroll-csr-err-certificate-authority-and-enrollment-pattern": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + EnrollmentPatternId: int32(enrollmentPatternId), + CertificateAuthorityLogicalName: certificateAuthorityLogicalName, + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr(fmt.Sprintf("Ensure that enrollment pattern ID %d has CSR Enrollment enabled, is configured to use certificate authority '%s', and the security role is configured to use this enrollment pattern.", enrollmentPatternId, certificateAuthorityLogicalName)), + }, + "enroll-csr-err-certificate-authority-and-template-name": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + CertificateAuthorityLogicalName: certificateAuthorityLogicalName, + CertificateTemplate: certificateTemplateName, + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr(fmt.Sprintf("Verify that certificate template '%s' exists in Command and belongs to certificate authority '%s'. If applicable, verify the Enrollment Pattern configuration in Keyfactor Command, and ensure CSR Enrollment is enabled.", certificateTemplateName, certificateAuthorityLogicalName)), + }, + "enroll-csr-err-certificate-authority-and-template-name-and-enrollment-pattern": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + CertificateAuthorityLogicalName: certificateAuthorityLogicalName, + CertificateTemplate: certificateTemplateName, + EnrollmentPatternId: int32(enrollmentPatternId), + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr(fmt.Sprintf("Verify that certificate template '%s' exists in Command and belongs to certificate authority '%s'. Verify the configuration for Enrollment Pattern ID %d in Keyfactor Command, and ensure CSR Enrollment is enabled.", certificateTemplateName, certificateAuthorityLogicalName, enrollmentPatternId)), + }, + "enroll-csr-err-certificateOwnerId": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + EnrollmentPatternId: 10, + OwnerRoleId: 1000, + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr("Verify the issuer's security role is assigned to the configured owner role"), + }, + "enroll-csr-err-certificateOwnerName": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + EnrollmentPatternId: 10, + OwnerRoleName: "foobar", + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr("Verify the issuer's security role is assigned to the configured owner role"), + }, + "enroll-csr-err-annotations": { + enrollCSRFunctionError: errors.New("an error from Command"), + // Request + config: &SignConfig{ + EnrollmentPatternId: 10, + Annotations: map[string]string{ + "metadata.command-issuer.keyfactor.com/fizz": "buzz", + }, + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr("Verify that the metadata fields provided exist in Command"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + cb := func(req v1.ApiCreateEnrollmentCSRRequest) { + require.NotNil(t, req) + } + + client := fakeClient{ + err: tc.enrollCSRFunctionError, + + enrollResponse: certificateRestResponseFromExpectedCerts(t, expectedLeafAndChain, []*x509.Certificate{caCert}), + enrollHTTPResponse: tc.enrollHTTPResponse, + enrollCallback: cb, + } + signer := signer{ + client: &client, + } + + csrBytes, err := generateCSR("CN=command.example.org", []string{"dns.example.com"}, []string{}, []string{}) + require.NoError(t, err) + csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes.Raw}) + + _, _, err = signer.Sign(context.Background(), csrPem, tc.config) + assert.ErrorIs(t, err, errCommandEnrollmentFailure) + assert.Contains(t, err.Error(), *tc.expectedErrorContent, "error message should contain content from response body when enrollCSR returns an error") + }) + } +} + func TestCommandSupportsMetadata(t *testing.T) { testCases := map[string]struct { presentMeta []v1.CSSCMSDataModelModelsMetadataType From 2f93d8d3e7088fa9d39ffd4606ecd33ec01b0da9 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 20 May 2026 21:30:41 +0000 Subject: [PATCH 05/14] Update generated docs --- README.md | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c6c50b0..923cf68 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ Before continuing, ensure that the following requirements are met: - [Keyfactor Command](https://www.keyfactor.com/products/command/) >= 10.5 - Command must be properly configured according to the [product docs](https://software.keyfactor.com/Core-OnPrem/Current/Content/MasterTopics/Portal.htm). - You have access to the Command REST API. The following endpoints must be available: - - `/Status/Endpoints` - - `/Enrollment/CSR` - - `/MetadataFields` - - `/EnrollmentPatterns` (Keyfactor Command 25.1 and above) + - [/Status/Endpoints](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/StatusGetEndpoints.htm) + - [/Enrollment/CSR](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/EnrollmentPOSTCSR.htm) + - [/MetadataFields](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/MetadataFieldsGet.htm) + - [/EnrollmentPatterns](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/Enrollment-Patterns-GET.htm) (Keyfactor Command 25.1 and above) - Kubernetes >= v1.19 - [Kubernetes](https://kubernetes.io/docs/tasks/tools/), [Minikube](https://minikube.sigs.k8s.io/docs/start/), [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/), etc. > You must have permission to create [Custom Resource Definitions](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/) in your Kubernetes cluster. @@ -258,7 +258,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | caBundleConfigMapName | (optional) The name of the Kubernetes ConfigMap containing the CA certificate trust chain. See the [CA Bundle docs](./docs/ca-bundle/README.md) for more information. | | caBundleKey | (optional) The name of the key in the ConfigMap or Secret specified by `caSecretName` or `caBundleConfigMapName` that contains the CA bundle. If omitted, the last key of the ConfigMap / Secret resource will be used. | - | certificateAuthorityLogicalName | The logical name of the Certificate Authority to use in Command. For example, `Sub-CA` | + | certificateAuthorityLogicalName | (Optional) The logical name of the Certificate Authority to use in Command. For example, `Sub-CA`. Optional if the enrollment pattern does not target a standalone CA. When not defined, Command will choose an eligible CA within the enrollment pattern's configuration tenant. | | certificateAuthorityHostname | (optional) The hostname of the Certificate Authority specified by `certificateAuthorityLogicalName`. This field is usually only required if the CA in Command is a DCOM (MSCA-like) CA. | | enrollmentPatternId | The ID of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternId` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. | | enrollmentPatternName | The Name of the [Enrollment Pattern](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Enrollment-Patterns.htm) to use when this Issuer/ClusterIssuer enrolls CSRs. **Supported by Keyfactor Command 25.1 and above**. If `certificateTemplate` and `enrollmentPatternName` are both specified, the enrollment pattern parameter will take precedence. If `enrollmentPatternId` and `enrollmentPatternName` are both specified, `enrollmentPatternId` will take precedence. Enrollment will fail if the specified certificate template is not compatible with the enrollment pattern. If using `enrollmentPatternName`, your security role must have `/enrollment_pattern/read/` permission. | @@ -273,6 +273,17 @@ For example, ClusterIssuer resources can be used to issue certificates for resou > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. + > **What is a standalone CA?** + > A standalone CA is a Certificate Authority in Keyfactor Command that is not configured + > as part of a CA pool within the enrollment pattern — typically a DCOM (Microsoft CA) + > configured as a standalone (non-Active-Directory-integrated) CA. When an enrollment + > pattern targets a standalone CA, Command cannot automatically select a CA from a pool + > and requires `certificateAuthorityLogicalName` to be explicitly provided. + > + > If you are unsure whether your CA is a standalone CA, check the CA type and configuration + > in Command under **Certificate Authorities**, or contact your Keyfactor support + > representative. + 2. **Create an Issuer or ClusterIssuer** - **Issuer** @@ -295,10 +306,10 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" - enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. - # enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required @@ -330,10 +341,10 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" - enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. - # enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required # scopes: "openid email https://example.com/.default" # Uncomment if required From 61213aa14c5836c7b2d465b5bd20f9e75210afc6 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 21 May 2026 15:56:15 -0400 Subject: [PATCH 06/14] chore: update docs + add e2e test for optional CA Signed-off-by: Matthew H. Irby --- docsource/content.md | 31 ++++++++++++++++++++----------- e2e/run_tests.sh | 10 ++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docsource/content.md b/docsource/content.md index 9f6c983..8305271 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -23,7 +23,7 @@ Before continuing, ensure that the following requirements are met: ## Configuring Command -Command Issuer enrolls certificates by submitting a POST request to the Command CSR Enrollment endpoint. Before using Command Issuer, you must create or identify a Certificate Authority _and_ Certificate Template / Enrollment Pattern suitable for your use case. Additionally, you should ensure that the [identity provider](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/AuthenticateAPI.htm#AuthenticatingtotheKeyfactorAPI) used by the Issuer/ClusterIssuer has the appropriate permissions in Command. +Command Issuer enrolls certificates by submitting certificate signing requests to the Keyfactor Command [CSR Enrollment API](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/EnrollmentPOSTCSR.htm). Before using Command Issuer, you must have a Certificate Authority and either a Certificate Template or an Enrollment Pattern configured in Keyfactor Command that is suitable for your use case. You must also configure an [identity provider](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/AuthenticateAPI.htm#AuthenticatingtotheKeyfactorAPI) in Keyfactor Command and map the credentials used by the Issuer or ClusterIssuer to a [security role](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/SecurityOverview.htm) with the appropriate permissions. 1. **Create or identify a Certificate Authority** @@ -80,7 +80,7 @@ Command Issuer enrolls certificates by submitting a POST request to the Command ## Installing Command Issuer -Command Issuer is installed using a Helm chart. The chart is available in the [Command cert-manager Helm repository](https://keyfactor.github.io/command-cert-manager-issuer/). +Command Issuer is installed using a Helm chart. The chart is available in the [Command cert-manager Helm repository](./deploy/charts/command-cert-manager-issuer). 1. Verify that at least one Kubernetes node is running: @@ -121,15 +121,24 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C ```shell helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ --namespace command-issuer-system \ - --version 2.4.0 + --version 2.4.0 \ --create-namespace ``` -> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) +> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [the command-cert-manager-issuer Helm chart documentation](./deploy/charts/command-cert-manager-issuer/README.md#configuration). > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. -> A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) +## Healthchecks + +By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 60 seconds to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. + +**For production environments, it is not recommended to disable healthchecks**. Although certificate requests are automatically retried if the Command API is unreachable, disabling healthchecks means the issuer remains in a `Ready` state during an outage. Requests will be forwarded to the Command API before failing, resulting in slower recovery and less actionable error messages compared to the clear `issuer is not ready` rejection produced when healthchecks are enabled. + +Healthcheck configuration options: +- `healthcheck.enabled` and `healthcheck.interval` fields on the [Issuer / ClusterIssuer specification](#creating-issuer-and-clusterissuer-resources) +- `defaultHealthCheckInterval` Helm value to configure the default interval for all Command Issuer resources in the Kubernetes cluster +- `default-health-check-interval` argument on the command-cert-manager-issuer Deployment container # Authentication @@ -140,7 +149,7 @@ Command Issuer supports explicit credentials authentication to Command using one - [Basic Authentication](#basic-auth) (username and password) - [OAuth 2.0 "client credentials" token flow](#oauth) (sometimes called two-legged OAuth 2.0) -These credentials must be configured using a Kubernetes Secret. By default, the secret is expected to exist in the same namespace as the issuer controller (`command-issuer-system` by default). +These credentials must be configured using a [Kubernetes Secret](https://kubernetes.io/docs/concepts/configuration/secret/). By default, the secret is expected to exist in the same namespace as the issuer controller (`command-issuer-system` by default). > Command Issuer can read secrets in the Issuer namespace if `--set "secretConfig.useClusterRoleForSecretAccess=true"` flag is set when installing the Helm chart. @@ -235,7 +244,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | @@ -274,9 +283,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standalone CA # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Uncomment and set if using Keyfactor Command 24.4 and below. enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required @@ -309,9 +318,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standalone CA # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Uncomment and set if using Keyfactor Command 24.4 and below. enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index fab2f06..8e7117b 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -1096,6 +1096,16 @@ check_certificate_request_status echo "🧪✅ Test 6 completed successfully." echo "" +echo "🧪💬 Test 7: Certificate Authority is optional when Enrollment Pattern is used" +regenerate_issuer +delete_issuer_specification_field certificateAuthorityLogicalName Issuer +add_issuer_specification_field enrollmentPatternName "$ENROLLMENT_PATTERN_NAME" Issuer +regenerate_certificate_request Issuer +approve_certificate_request +check_certificate_request_status +echo "🧪✅ Test 7 completed successfully." +echo "" + ## =================== END: Issuer & ClusterIssuer Tests ============================ ## =================== BEGIN: Annotation Tests ============================ From 155f15db041f9a66d78f9348c9aa3e97632cb0d4 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Thu, 21 May 2026 19:57:52 +0000 Subject: [PATCH 07/14] Update generated docs --- README.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 923cf68..fcf50b6 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Before continuing, ensure that the following requirements are met: ## Configuring Command -Command Issuer enrolls certificates by submitting a POST request to the Command CSR Enrollment endpoint. Before using Command Issuer, you must create or identify a Certificate Authority _and_ Certificate Template / Enrollment Pattern suitable for your use case. Additionally, you should ensure that the [identity provider](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/AuthenticateAPI.htm#AuthenticatingtotheKeyfactorAPI) used by the Issuer/ClusterIssuer has the appropriate permissions in Command. +Command Issuer enrolls certificates by submitting certificate signing requests to the Keyfactor Command [CSR Enrollment API](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/EnrollmentPOSTCSR.htm). Before using Command Issuer, you must have a Certificate Authority and either a Certificate Template or an Enrollment Pattern configured in Keyfactor Command that is suitable for your use case. You must also configure an [identity provider](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/AuthenticateAPI.htm#AuthenticatingtotheKeyfactorAPI) in Keyfactor Command and map the credentials used by the Issuer or ClusterIssuer to a [security role](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/SecurityOverview.htm) with the appropriate permissions. 1. **Create or identify a Certificate Authority** @@ -112,7 +112,7 @@ Command Issuer enrolls certificates by submitting a POST request to the Command ## Installing Command Issuer -Command Issuer is installed using a Helm chart. The chart is available in the [Command cert-manager Helm repository](https://keyfactor.github.io/command-cert-manager-issuer/). +Command Issuer is installed using a Helm chart. The chart is available in the [Command cert-manager Helm repository](./deploy/charts/command-cert-manager-issuer). 1. Verify that at least one Kubernetes node is running: @@ -153,15 +153,24 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C ```shell helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ --namespace command-issuer-system \ - --version 2.4.0 + --version 2.4.0 \ --create-namespace ``` -> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [this list](./deploy/charts/command-cert-manager-issuer/README.md#configuration) +> For all possible configuration values for the command-cert-manager-issuer Helm chart, please refer to [the command-cert-manager-issuer Helm chart documentation](./deploy/charts/command-cert-manager-issuer/README.md#configuration). > The Helm chart installs the Command Issuer CRDs by default. The CRDs can be installed manually with the `make install` target. -> A list of configurable Helm chart parameters can be found [in the Helm chart docs](./deploy/charts/command-cert-manager-issuer/README.md#configuration) +## Healthchecks + +By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 60 seconds to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. + +**For production environments, it is not recommended to disable healthchecks**. Although certificate requests are automatically retried if the Command API is unreachable, disabling healthchecks means the issuer remains in a `Ready` state during an outage. Requests will be forwarded to the Command API before failing, resulting in slower recovery and less actionable error messages compared to the clear `issuer is not ready` rejection produced when healthchecks are enabled. + +Healthcheck configuration options: +- `healthcheck.enabled` and `healthcheck.interval` fields on the [Issuer / ClusterIssuer specification](#creating-issuer-and-clusterissuer-resources) +- `defaultHealthCheckInterval` Helm value to configure the default interval for all Command Issuer resources in the Kubernetes cluster +- `default-health-check-interval` argument on the command-cert-manager-issuer Deployment container # Authentication @@ -172,7 +181,7 @@ Command Issuer supports explicit credentials authentication to Command using one - [Basic Authentication](#basic-auth) (username and password) - [OAuth 2.0 "client credentials" token flow](#oauth) (sometimes called two-legged OAuth 2.0) -These credentials must be configured using a Kubernetes Secret. By default, the secret is expected to exist in the same namespace as the issuer controller (`command-issuer-system` by default). +These credentials must be configured using a [Kubernetes Secret](https://kubernetes.io/docs/concepts/configuration/secret/). By default, the secret is expected to exist in the same namespace as the issuer controller (`command-issuer-system` by default). > Command Issuer can read secrets in the Issuer namespace if `--set "secretConfig.useClusterRoleForSecretAccess=true"` flag is set when installing the Helm chart. @@ -267,7 +276,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If left disabled, the issuer will not perform a health check when the issuer is healthy and may cause CertificateRequest resources to silently fail. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | @@ -306,9 +315,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standalone CA # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Uncomment and set if using Keyfactor Command 24.4 and below. enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required @@ -341,9 +350,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou # caBundleKey: "ca.crt" # references the key in the secret/configmap containing the CA trust chain (see CA Bundle docs for more info) # certificateAuthorityHostname: "$COMMAND_CA_HOSTNAME" # Uncomment if required - # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standlone CA + # certificateAuthorityLogicalName: "$COMMAND_CA_LOGICAL_NAME" # Required if using Keyfactor Command 24.4 and below or if enrollment pattern is associated with a standalone CA # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. - # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Required if using Keyfactor Command 24.4 and below. + # certificateTemplate: "$CERTIFICATE_TEMPLATE_SHORT_NAME" # Uncomment and set if using Keyfactor Command 24.4 and below. enrollmentPatternName: "$ENROLLMENT_PATTERN_NAME" # Only supported on Keyfactor Command 25.1 and above. # ownerRoleId: "$OWNER_ROLE_ID" # Uncomment if required # ownerRoleName: "$OWNER_ROLE_NAME" # Uncomment if required From 4fb607b24fdb3447edcf47753ef2c3bd6980c055 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 26 May 2026 12:31:28 -0400 Subject: [PATCH 08/14] feat(healthcheck): bump default healthcheck interval from 1m to 10m Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 1 + api/v1alpha1/issuer_types.go | 2 +- cmd/main.go | 2 +- ...d-issuer.keyfactor.com_clusterissuers.yaml | 3 ++- .../command-issuer.keyfactor.com_issuers.yaml | 3 ++- .../command-cert-manager-issuer/README.md | 2 +- .../templates/crds/clusterissuers.yaml | 2 +- .../templates/crds/issuers.yaml | 2 +- docsource/content.md | 9 +++++--- internal/controller/issuer_controller_test.go | 22 ++++++++++--------- 10 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7083b49..4156479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # v2.6.0 ## Features - Allow `certificateAuthorityLogicalName` to be optional when using an enrollment pattern. +- The default healthcheck interval has been bumped from 1 minute to 10 minutes. ## Security - The Helm chart now defaults `serviceAccount.automountServiceAccountToken` to `false`, diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 73deea4..85328b1 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -304,7 +304,7 @@ type HealthCheckConfig struct { // Determines whether to enable the health check when the issuer is healthy. Default: true Enabled bool `json:"enabled"` - // The interval at which to health check the issuer when healthy. Defaults to 1 minute. Must not be less than "30s". + // The interval at which to health check the issuer when healthy. Defaults to 10 minutes. Must not be less than "30s". // +kubebuilder:validation:Optional Interval *metav1.Duration `json:"interval,omitempty"` } diff --git a/cmd/main.go b/cmd/main.go index 124a8f4..bf4e603 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -82,7 +82,7 @@ func main() { "If set the metrics endpoint is served securely") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") - flag.StringVar(&healthCheckInterval, "default-health-check-interval", "60s", + flag.StringVar(&healthCheckInterval, "default-health-check-interval", "10m", // 10 minutes "If set, it is the default health check interval for issuers.") flag.StringVar(&clusterResourceNamespace, "cluster-resource-namespace", "", "The namespace for secrets in which cluster-scoped resources are found.") flag.BoolVar(&disableApprovedCheck, "disable-approved-check", false, diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 55fcf5b..f03451e 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -129,7 +129,8 @@ spec: type: boolean interval: description: The interval at which to health check the issuer - when healthy. Defaults to 1 minute. Must not be less than "30s". + when healthy. Defaults to 10 minutes. Must not be less than + "30s". type: string required: - enabled diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index ceef0ce..5dacd46 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -129,7 +129,8 @@ spec: type: boolean interval: description: The interval at which to health check the issuer - when healthy. Defaults to 1 minute. Must not be less than "30s". + when healthy. Defaults to 10 minutes. Must not be less than + "30s". type: string required: - enabled diff --git a/deploy/charts/command-cert-manager-issuer/README.md b/deploy/charts/command-cert-manager-issuer/README.md index 0d64052..d076eef 100644 --- a/deploy/charts/command-cert-manager-issuer/README.md +++ b/deploy/charts/command-cert-manager-issuer/README.md @@ -85,4 +85,4 @@ The following table lists the configurable parameters of the `command-cert-manag | `tolerations` | Tolerations for pod assignment | `[]` | | `env` | Environmental variables set for pod | `{}` | | `secretConfig.useClusterRoleForSecretAccess` | Specifies if the ServiceAccount should be granted access to the Secret resource using a ClusterRole | `false` | -| `defaultHealthCheckInterval` | Specifies the default health check interval for issuers | `""` (uses the default in the code which is 60s) | +| `defaultHealthCheckInterval` | Specifies the default health check interval for issuers | `""` (uses the default in the code which is 10 minutes) | diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml index 2328418..cb827fe 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -106,7 +106,7 @@ spec: type: boolean interval: description: The interval at which to health check the issuer - when healthy. Defaults to 1 minute. + when healthy. Defaults to 10 minutes. type: string required: - enabled diff --git a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml index f3d5387..d5f70e0 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -106,7 +106,7 @@ spec: type: boolean interval: description: The interval at which to health check the issuer - when healthy. Defaults to 1 minute. + when healthy. Defaults to 10 minutes. type: string required: - enabled diff --git a/docsource/content.md b/docsource/content.md index 8305271..6efc964 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -131,7 +131,7 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C ## Healthchecks -By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 60 seconds to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. +By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 10 minutes to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. **For production environments, it is not recommended to disable healthchecks**. Although certificate requests are automatically retried if the Command API is unreachable, disabling healthchecks means the issuer remains in a `Ready` state during an outage. Requests will be forwarded to the Command API before failing, resulting in slower recovery and less actionable error messages compared to the clear `issuer is not ready` rejection produced when healthchecks are enabled. @@ -140,6 +140,9 @@ Healthcheck configuration options: - `defaultHealthCheckInterval` Helm value to configure the default interval for all Command Issuer resources in the Kubernetes cluster - `default-health-check-interval` argument on the command-cert-manager-issuer Deployment container +> [!NOTE] +> The interval format for healthchecks is a decimal number with unit syntax ([docs](https://pkg.go.dev/time#ParseDuration)). Examples: `10m`, `30s`, `1.5h`. The minimum allowed interval is 30 seconds (`30s`). + # Authentication ## Explicit Credentials @@ -244,9 +247,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 5 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | - | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | + | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `10m`. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 54fda8b..371a1c2 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -60,6 +60,8 @@ var newFakeHealthCheckerBuilder = func(builderErr error, checkerErr error, suppo } } +const defaultHealthcheckInterval = time.Minute * 10 + func TestIssuerReconcile(t *testing.T) { type testCase struct { kind string @@ -115,7 +117,7 @@ func TestIssuerReconcile(t *testing.T) { healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: time.Minute}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "issuer-basicauth-no-username": { kind: "Issuer", @@ -237,7 +239,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: time.Minute}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "success-issuer-oauth": { kind: "Issuer", @@ -278,7 +280,7 @@ func TestIssuerReconcile(t *testing.T) { healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: time.Minute}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "issuer-oauth-no-tokenurl": { kind: "Issuer", @@ -448,7 +450,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, - expectedResult: ctrl.Result{RequeueAfter: time.Minute}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "issuer-kind-Unrecognized": { kind: "UnrecognizedType", @@ -734,7 +736,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, - expectedResult: ctrl.Result{RequeueAfter: time.Minute}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "success-nil-healthcheck-interval-defaults": { kind: "ClusterIssuer", @@ -776,7 +778,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, - expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "success-default-healthcheck-interval": { kind: "ClusterIssuer", @@ -855,7 +857,7 @@ func TestIssuerReconcile(t *testing.T) { clusterResourceNamespace: "kube-system", expectedReadyConditionStatus: commandissuer.ConditionTrue, expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, - expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, + expectedResult: ctrl.Result{RequeueAfter: defaultHealthcheckInterval}, }, "error-healthcheck-minimum-value": { kind: "Issuer", @@ -916,10 +918,10 @@ func TestIssuerReconcile(t *testing.T) { tc.kind = "Issuer" } - defaultHealthcheckInterval := time.Minute + testCaseHealthcheckInterval := defaultHealthcheckInterval if tc.defaultHealthCheckInterval != nil { - defaultHealthcheckInterval = *tc.defaultHealthCheckInterval + testCaseHealthcheckInterval = *tc.defaultHealthCheckInterval } controller := IssuerReconciler{ @@ -929,7 +931,7 @@ func TestIssuerReconcile(t *testing.T) { HealthCheckerBuilder: tc.healthCheckerBuilder, ClusterResourceNamespace: tc.clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: true, - DefaultHealthCheckInterval: defaultHealthcheckInterval, + DefaultHealthCheckInterval: testCaseHealthcheckInterval, } result, err := controller.Reconcile( From ab0ae9a8882e611ddb7f05725112a35eb368d025 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 26 May 2026 15:02:43 -0400 Subject: [PATCH 09/14] chore(deps): bump go version to 1.26 Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 1 + Dockerfile | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4156479..338da35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ## Security - The Helm chart now defaults `serviceAccount.automountServiceAccountToken` to `false`, replacing the long-lived auto-mounted token with a short-lived projected token (~1 hour, automatically rotated by kubelet). The token is still mounted at the standard path `/var/run/secrets/kubernetes.io/serviceaccount` so no application changes are required. +- Go version has been bumped from 1.24 to 1.26.2+ to fix CVE-2026-27143 (affects Go compiler versions below 1.25.9, and 1.26.0-1.26.1). > [!IMPORTANT] > diff --git a/Dockerfile b/Dockerfile index d5f88f3..56ae630 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.24 AS builder +FROM golang:1.26 AS builder ARG TARGETOS ARG TARGETARCH diff --git a/go.mod b/go.mod index d2f1d31..3639173 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Keyfactor/command-cert-manager-issuer -go 1.24.0 +go 1.26.2 require ( github.com/Keyfactor/keyfactor-auth-client-go v1.3.1 From 055a6408267408bf5e11450b763c9f71568f8350 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 26 May 2026 19:04:24 +0000 Subject: [PATCH 10/14] Update generated docs --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fcf50b6..8792843 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C ## Healthchecks -By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 60 seconds to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. +By default, Issuer and ClusterIssuer resources are deployed with a healthcheck that queries the Keyfactor Command API every 10 minutes to verify connectivity. If the healthcheck fails, the issuer marks itself unhealthy and pauses processing certificate requests until connectivity is restored — at which point it automatically recovers without operator intervention. Healthcheck intervals can be modified according to your environment's needs. **For production environments, it is not recommended to disable healthchecks**. Although certificate requests are automatically retried if the Command API is unreachable, disabling healthchecks means the issuer remains in a `Ready` state during an outage. Requests will be forwarded to the Command API before failing, resulting in slower recovery and less actionable error messages compared to the clear `issuer is not ready` rejection produced when healthchecks are enabled. @@ -172,6 +172,9 @@ Healthcheck configuration options: - `defaultHealthCheckInterval` Helm value to configure the default interval for all Command Issuer resources in the Kubernetes cluster - `default-health-check-interval` argument on the command-cert-manager-issuer Deployment container +> [!NOTE] +> The interval format for healthchecks is a decimal number with unit syntax ([docs](https://pkg.go.dev/time#ParseDuration)). Examples: `10m`, `30s`, `1.5h`. The minimum allowed interval is 30 seconds (`30s`). + # Authentication ## Explicit Credentials @@ -276,9 +279,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 60 seconds. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 5 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | - | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `60s`. | + | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `10m`. | > If a different combination of hostname/certificate authority/certificate template is required, a new Issuer or ClusterIssuer resource must be created. Each resource instantiation represents a single configuration. From c71807346760dd6f04827aba5c116a12a155a19c Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 27 May 2026 08:36:50 -0400 Subject: [PATCH 11/14] chore: address copilot feedback. update linter version Signed-off-by: Matthew H. Irby --- .github/workflows/test.yml | 2 +- .golangci.yml | 8 ++++++++ Makefile | 2 +- .../command-cert-manager-issuer/templates/deployment.yaml | 3 +++ docsource/content.md | 2 +- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a843b9d..cfdae7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - name: Run linters uses: golangci/golangci-lint-action@v7 with: - version: v2.4.0 + version: v2.12.2 - name: Regenerate CRDs run: make generate manifests diff --git a/.golangci.yml b/.golangci.yml index bd04587..62b6538 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,6 +32,14 @@ linters: - third_party$ - builtin$ - examples$ + rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec formatters: enable: - goimports diff --git a/Makefile b/Makefile index caf2534..abe5938 100644 --- a/Makefile +++ b/Makefile @@ -192,7 +192,7 @@ CONFTEST = $(LOCALBIN)/conftest-$(CONFTEST_VERSION) KUSTOMIZE_VERSION ?= v5.3.0 CONTROLLER_TOOLS_VERSION ?= v0.14.0 ENVTEST_VERSION ?= latest -GOLANGCI_LINT_VERSION ?= v2.4.0 +GOLANGCI_LINT_VERSION ?= v2.12.2 KUBE_LINTER_VERSION ?= v0.6.8 CONFTEST_VERSION ?= v0.60.0 diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index 74018fd..2f5de7d 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -26,6 +26,9 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "command-cert-manager-issuer.serviceAccountName" . }} + {{- if not .Values.serviceAccount.automountServiceAccountToken }} + automountServiceAccountToken: false + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} {{- if not .Values.serviceAccount.automountServiceAccountToken }} diff --git a/docsource/content.md b/docsource/content.md index 6efc964..b91edb4 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -247,7 +247,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 5 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 10 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `10m`. | From 2447d240e07c300ee4ba785cae8c4e7a2977ba85 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 27 May 2026 12:37:58 +0000 Subject: [PATCH 12/14] Update generated docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8792843..d6b6e88 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | ownerRoleName | The name of the security role assigned as the certificate owner. The security role must be assigned to the identity context of the issuer. If `ownerRoleId` and `ownerRoleName` are both specified, `ownerRoleId` will take precedence. This field is **required** if the enrollment pattern, certificate template, or system-wide setting requires it. | | scopes | (Optional) Required if using ambient credentials with Azure AKS. If using ambient credentials, these scopes will be put on the access token generated by the ambient credentials' token provider, if applicable. | | audience | (Optional) If using ambient credentials, this audience will be put on the access token generated by the ambient credentials' token provider, if applicable. Google's ambient credential token provider generates an OIDC ID Token. If this value is not provided, it will default to `command`. | - | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 5 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | + | healthcheck | (Optional) Defines the health check configuration for the issuer. If omitted, health checks will be enabled and default to 10 minutes. If disabled, the issuer will remain in a Ready state during outages and forward requests to the Command API before failing. Although requests are retried automatically, issues are harder to diagnose without health checks. | | healthcheck.enabled | (Required if health check block provided) Boolean to enable / disable health checks. By default, health checks are enabled. | | healthcheck.interval | (Optional) Defines the interval between health checks. Example values: `30s`, `1m`, `5.5m`. To prevent overloading the Command instance, this interval must not be less than `30s`. Default value: `10m`. | From d40571da6cab2c67e7328cce47db1ecd7dc102dd Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 27 May 2026 09:10:48 -0400 Subject: [PATCH 13/14] chore(ci): bump controller tools version Signed-off-by: Matthew H. Irby --- Makefile | 2 +- .../crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml | 2 +- config/crd/bases/command-issuer.keyfactor.com_issuers.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index abe5938..023345b 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,7 @@ CONFTEST = $(LOCALBIN)/conftest-$(CONFTEST_VERSION) ## Tool Versions KUSTOMIZE_VERSION ?= v5.3.0 -CONTROLLER_TOOLS_VERSION ?= v0.14.0 +CONTROLLER_TOOLS_VERSION ?= v0.17.3 ENVTEST_VERSION ?= latest GOLANGCI_LINT_VERSION ?= v2.12.2 KUBE_LINTER_VERSION ?= v0.6.8 diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index f03451e..73fab0a 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.17.3 name: clusterissuers.command-issuer.keyfactor.com spec: group: command-issuer.keyfactor.com diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 5dacd46..0138d7c 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.17.3 name: issuers.command-issuer.keyfactor.com spec: group: command-issuer.keyfactor.com From 41c9e455d7275fb22c18f5dfd3fa90f6d28b5bcd Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 1 Jun 2026 09:12:40 -0400 Subject: [PATCH 14/14] chore(docs): document new Helm chart values for serviceAccount Signed-off-by: Matthew H. Irby --- deploy/charts/command-cert-manager-issuer/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/charts/command-cert-manager-issuer/README.md b/deploy/charts/command-cert-manager-issuer/README.md index d076eef..3c83a88 100644 --- a/deploy/charts/command-cert-manager-issuer/README.md +++ b/deploy/charts/command-cert-manager-issuer/README.md @@ -77,6 +77,9 @@ The following table lists the configurable parameters of the `command-cert-manag | `serviceAccount.create` | Specifies if a service account should be created | `true` | | `serviceAccount.annotations` | Annotations to add to the service account | `{}` | | `serviceAccount.name` | Name of the service account to use | `""` (uses the fullname template if `create` is true) | +| `serviceAccount.automountServiceAccountToken` | Controls whether Kubernetes automatically mounts the service account token into the pod. When `false` (default), a projected volume is used instead, giving explicit control over token expiration and file permissions. Setting to `true` uses Kubernetes' default token mount, which has no expiration and is less restrictive — only recommended if the projected volume approach causes compatibility issues. | `false` | +| `serviceAccount.projectedTokenVolume.expirationSeconds` | Lifetime in seconds of the projected service account token. The kubelet will rotate the token before it expires. Only applies when `automountServiceAccountToken` is `false`. | `3607` | +| `serviceAccount.projectedTokenVolume.defaultMode` | File permission bits for the projected token volume. Only applies when `automountServiceAccountToken` is `false`. | `0444` | | `podAnnotations` | Annotations for the pod | `{}` | | `podSecurityContext.runAsNonRoot` | Run pod as non-root | `true` | | `securityContext` | Security context for the pod | `{}` (with commented out options) |