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/CHANGELOG.md b/CHANGELOG.md index fb8ab7f..382671b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +# 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`, + 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] +> +> ### 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.3 ## Security - Updated dependencies to address various security vulnerabilities: 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/Makefile b/Makefile index caf2534..023345b 100644 --- a/Makefile +++ b/Makefile @@ -190,9 +190,9 @@ 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.4.0 +GOLANGCI_LINT_VERSION ?= v2.12.2 KUBE_LINTER_VERSION ?= v0.6.8 CONFTEST_VERSION ?= v0.60.0 diff --git a/README.md b/README.md index c6c50b0..d6b6e88 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. @@ -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,27 @@ 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 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. + +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 + +> [!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 @@ -172,7 +184,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. @@ -258,7 +270,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. | @@ -267,12 +279,23 @@ 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 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: `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. + > **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 +318,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 standalone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # 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 # scopes: "openid email https://example.com/.default" # Uncomment if required @@ -330,10 +353,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 standalone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # 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 # scopes: "openid email https://example.com/.default" # Uncomment if required diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index deba1d6..85328b1 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 @@ -301,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 33f2b32..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 @@ -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: @@ -127,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 27db089..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 @@ -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: @@ -127,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..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) | @@ -85,4 +88,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 4206341..cb827fe 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: @@ -104,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 efb2dea..d5f70e0 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: @@ -104,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/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index 34e3bd1..2f5de7d 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -26,8 +26,32 @@ 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 }} + volumes: + - name: serviceaccount-token + projected: + defaultMode: {{ .Values.serviceAccount.projectedTokenVolume.defaultMode }} + sources: + - 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: - --health-probe-bind-address=:8081 @@ -67,6 +91,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: {} diff --git a/docsource/content.md b/docsource/content.md index 99cd071..b91edb4 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. @@ -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,27 @@ 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 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. + +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 + +> [!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 @@ -140,7 +152,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. @@ -226,7 +238,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. | @@ -235,12 +247,23 @@ 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 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: `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. + > **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 +286,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 standalone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # 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 # scopes: "openid email https://example.com/.default" # Uncomment if required @@ -298,10 +321,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 standalone CA + # enrollmentPatternId: "$ENROLLMENT_PATTERN_ID" # Only supported on Keyfactor Command 25.1 and above. + # 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 # scopes: "openid email https://example.com/.default" # 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 ============================ diff --git a/go.mod b/go.mod index 0dcacbc..6583b09 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 diff --git a/internal/command/command.go b/internal/command/command.go index a88a3d5..2af71e4 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 @@ -398,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 @@ -689,3 +702,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..99f8ac4 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: "", }, { @@ -649,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 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(