From 4fc19b03a0104041b94784b2319b71f38e6b65e2 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 22 Jan 2026 18:02:08 -0500 Subject: [PATCH 1/4] feat: release 2.5.0 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation --- CHANGELOG.md | 11 + Makefile | 2 +- README.md | 36 +- api/v1alpha1/issuer_types.go | 16 +- cmd/main.go | 36 +- ...d-issuer.keyfactor.com_clusterissuers.yaml | 16 +- .../command-issuer.keyfactor.com_issuers.yaml | 16 +- .../command-cert-manager-issuer/README.md | 1 + .../templates/crds/clusterissuers.yaml | 16 +- .../templates/crds/issuers.yaml | 16 +- .../templates/deployment.yaml | 7 + .../templates/role.yaml | 16 + .../templates/rolebinding.yaml | 15 + .../command-cert-manager-issuer/values.yaml | 16 + docs/ambient-providers/google.md | 447 ++++++++++++ docs/ca-bundle/README.md | 330 +++++++++ docsource/content.md | 36 +- e2e/.env.example | 5 +- e2e/.gitignore | 2 + e2e/README.md | 13 +- e2e/certs/.gitkeep | 0 e2e/run_tests.sh | 236 +++++- internal/command/client.go | 7 +- internal/controller/issuer_controller.go | 45 +- internal/controller/issuer_controller_test.go | 669 +++++++++++++++++- 25 files changed, 1960 insertions(+), 50 deletions(-) create mode 100644 docs/ambient-providers/google.md create mode 100644 docs/ca-bundle/README.md create mode 100644 e2e/.gitignore create mode 100644 e2e/certs/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e62fc..c78ea9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# v2.5.0 +## Features +- Add support to specify a ConfigMap for CA trust bundles in Issuer / ClusterIssuer resources via the `caBundleConfigMapName` specification. +- Add support for specifying a key on a Secret / ConfigMap resource for the CA trust bundle via the `caBundleKey` specification on an Issuer / ClusterIssuer resource. +- Add a timeout when fetching ambient Azure credentials to move onto other ambient credential methods. +- Ability to specify environment variables on issuer deployment to set additional configuration options (i.e. HTTP proxy settings, etc.) + +## Chores +- Add documentation for how to configure command-cert-manager-issuer with ambient credentials on Google Kubernetes Engine (GKE). +- Add documentation for configuring CA trust bundles via Secret and ConfigMap resources using trust-manager. + # v2.4.0 ## Features - Add a `healthcheck` specification to Issuer / ClusterIssuer resources, allowing flexibility in the health check interval. diff --git a/Makefile b/Makefile index e59af68..f30d196 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ test: manifests generate fmt vet envtest ## Run tests. # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. test-e2e: - go test ./test/e2e/ -v -ginkgo.v + cd e2e && source .env && ./run_tests.sh .PHONY: lint lint: golangci-lint ## Run golangci-lint linter & yamllint diff --git a/README.md b/README.md index daa7636..c6c50b0 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,25 @@ Command Issuer is installed using a Helm chart. The chart is available in the [C --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) + You can also install a specific version of the command-cert-manager-issuer Helm chart: + + ```shell + helm search repo command-issuer/command-cert-manager-issuer --versions + ``` + + ```shell + helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ + --namespace command-issuer-system \ + --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) > 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) + # Authentication ## Explicit Credentials @@ -166,6 +181,7 @@ These credentials must be configured using a Kubernetes Secret. By default, the Command Issuer also supports ambient authentication, where a token is fetched from an Authorization Server using a cloud provider's auth infrastructure and passed to Command directly. The following methods are supported: - [Managed Identity Using Azure Entra ID Workload Identity](./docs/ambient-providers/azure.md) (if running in [AKS](https://azure.microsoft.com/en-us/products/kubernetes-service)) +- [Workload Identity Using Google Kubernetes Engine](./docs/ambient-providers/google.md) (if running in [GKE](https://cloud.google.com/kubernetes-engine)) If you are running your Kubernetes workload in a cloud provider not listed above, you can use workload identity federation with [Azure AD](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation). @@ -212,11 +228,7 @@ This section has moved. Please refer to [this link](./docs/ambient-providers/azu # CA Bundle -If the Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate must be provided as a Kubernetes secret. - -```shell -kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt -``` +This section has been moved. Please refer to the new [CA Bundle docs](./docs/ca-bundle/README.md) documentation regarding CA trust with command-cert-manager-issuer. # Creating Issuer and ClusterIssuer resources @@ -243,7 +255,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | hostname | The hostname of the Command API Server. | | apiPath | (optional) The base path of the Command REST API. Defaults to `KeyfactorAPI`. | | commandSecretName | (optional) The name of the Kubernetes secret containing basic auth credentials or OAuth 2.0 credentials. Omit if using ambient credentials. | - | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate. Required if the Command API uses a self-signed certificate or it was signed by a CA that is not widely trusted. | + | 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` | | 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. | @@ -276,7 +290,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # 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" @@ -309,7 +325,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # 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" diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 418f47b..2b5b669 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -106,10 +106,24 @@ type IssuerSpec struct { // The name of the secret containing the CA bundle to use when verifying // Command's server certificate. If specified, the CA bundle will be added to - // the client trust roots for the Command issuer. + // the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + // are specified, caBundleConfigMapName will take precedence. // +optional CaSecretName string `json:"caSecretName,omitempty"` + // The name of the ConfigMap containing the CA bundle to use when verifying + // Command's server certificate. If specified, the CA bundle will be added to + // the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + // are specified, caBundleConfigMapName will take precedence. + // +optional + CaBundleConfigMapName string `json:"caBundleConfigMapName,omitempty"` + + // The key in the Secret or ConfigMap containing the CA certificate bundle. + // Applies to both caSecretName and caBundleConfigMapName. + // If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. + // +optional + CaBundleKey string `json:"caBundleKey,omitempty"` + // A list of comma separated scopes used when requesting a Bearer token from an ambient token provider implied // by the environment, rather than by commandSecretName. For example, could be set to // api://{tenant ID}/.default when requesting an access token for Entra ID (DefaultAzureCredential). Has no diff --git a/cmd/main.go b/cmd/main.go index 3a35bab..1611f84 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -71,6 +71,7 @@ func main() { var clusterResourceNamespace string var disableApprovedCheck bool var secretAccessGrantedAtClusterLevel bool + var configMapAccessGrantedAtClusterLevel bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -88,6 +89,8 @@ func main() { "Disables waiting for CertificateRequests to have an approved condition before signing.") flag.BoolVar(&secretAccessGrantedAtClusterLevel, "secret-access-granted-at-cluster-level", false, "Set this flag to true if the secret access is granted at cluster level. This will allow the controller to access secrets in any namespace. ") + flag.BoolVar(&configMapAccessGrantedAtClusterLevel, "configmap-access-granted-at-cluster-level", false, + "Set this flag to true if the config map access is granted at cluster level. This will allow the controller to access config maps in any namespace. ") opts := zap.Options{ Development: true, } @@ -130,16 +133,31 @@ func main() { } var cacheOpts cache.Options - if secretAccessGrantedAtClusterLevel { - setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 Secret resources at cluster level") - } else { - setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 Secret resources in the %q namespace", clusterResourceNamespace)) + + // Build the ByObject map if either resource is namespace-scoped + if !secretAccessGrantedAtClusterLevel || !configMapAccessGrantedAtClusterLevel { + byObject := make(map[client.Object]cache.ByObject) + + if !secretAccessGrantedAtClusterLevel { + setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 Secret resources in the %q namespace", clusterResourceNamespace)) + byObject[&corev1.Secret{}] = cache.ByObject{ + Namespaces: map[string]cache.Config{clusterResourceNamespace: {}}, + } + } else { + setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 Secret resources at cluster level") + } + + if !configMapAccessGrantedAtClusterLevel { + setupLog.Info(fmt.Sprintf("expecting SA to have Get+List+Watch permissions for corev1 ConfigMap resources in the %q namespace", clusterResourceNamespace)) + byObject[&corev1.ConfigMap{}] = cache.ByObject{ + Namespaces: map[string]cache.Config{clusterResourceNamespace: {}}, + } + } else { + setupLog.Info("expecting SA to have Get+List+Watch permissions for corev1 ConfigMap resources at cluster level") + } + cacheOpts = cache.Options{ - ByObject: map[client.Object]cache.ByObject{ - &corev1.Secret{}: { - Namespaces: map[string]cache.Config{clusterResourceNamespace: cache.Config{}}, - }, - }, + ByObject: byObject, } } diff --git a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml index 5d7a568..33f2b32 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_clusterissuers.yaml @@ -52,11 +52,25 @@ spec: the URL of your Command environment.Has no effect on OAuth 2.0 Client Credential configuration - please specify the audience for this method in an Opaque secret. type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml index 3695476..27db089 100644 --- a/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml +++ b/config/crd/bases/command-issuer.keyfactor.com_issuers.yaml @@ -52,11 +52,25 @@ spec: the URL of your Command environment.Has no effect on OAuth 2.0 Client Credential configuration - please specify the audience for this method in an Opaque secret. type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/README.md b/deploy/charts/command-cert-manager-issuer/README.md index b26bb88..0d64052 100644 --- a/deploy/charts/command-cert-manager-issuer/README.md +++ b/deploy/charts/command-cert-manager-issuer/README.md @@ -83,5 +83,6 @@ The following table lists the configurable parameters of the `command-cert-manag | `resources` | CPU/Memory resource requests/limits | `{}` (with commented out options) | | `nodeSelector` | Node labels for pod assignment | `{}` | | `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) | 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 f45d041..4206341 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/clusterissuers.yaml @@ -46,11 +46,25 @@ spec: description: APIPath is the base path of the Command API. KeyfactorAPI by default type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- 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 10a5214..efb2dea 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/crds/issuers.yaml @@ -46,11 +46,25 @@ spec: description: APIPath is the base path of the Command API. KeyfactorAPI by default type: string + caBundleConfigMapName: + description: |- + The name of the ConfigMap containing the CA bundle to use when verifying + Command's server certificate. If specified, the CA bundle will be added to + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. + type: string + caBundleKey: + description: |- + The key in the Secret or ConfigMap containing the CA certificate bundle. + Applies to both caSecretName and caBundleConfigMapName. + If unspecified, the last key alphabetically in the Secret or ConfigMap data will be used. + type: string caSecretName: description: |- The name of the secret containing the CA bundle to use when verifying Command's server certificate. If specified, the CA bundle will be added to - the client trust roots for the Command issuer. + the client trust roots for the Command issuer. If both caSecretName and caBundleConfigMapName + are specified, caBundleConfigMapName will take precedence. type: string certificateAuthorityHostname: description: |- diff --git a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml index 4725013..34e3bd1 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/deployment.yaml @@ -36,11 +36,18 @@ spec: {{- if .Values.secretConfig.useClusterRoleForSecretAccess}} - --secret-access-granted-at-cluster-level {{- end}} + {{- if .Values.secretConfig.useClusterRoleForConfigMapAccess}} + - --configmap-access-granted-at-cluster-level + {{- end}} {{- if .Values.defaultHealthCheckInterval }} - --default-health-check-interval={{ .Values.defaultHealthCheckInterval }} {{- end }} command: - /manager + {{- with .Values.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.Version }}" imagePullPolicy: {{ .Values.image.pullPolicy }} livenessProbe: diff --git a/deploy/charts/command-cert-manager-issuer/templates/role.yaml b/deploy/charts/command-cert-manager-issuer/templates/role.yaml index 9c8617d..eff6781 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/role.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/role.yaml @@ -40,3 +40,19 @@ rules: - get - list - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRole{{ else }}Role{{ end }} +metadata: + labels: + {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-role +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch diff --git a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml index 631df66..1125fd9 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml @@ -27,3 +27,18 @@ subjects: - kind: ServiceAccount name: {{ include "command-cert-manager-issuer.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRoleBinding{{ else }}RoleBinding{{ end }} +metadata: + labels: + {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRole{{ else }}Role{{ end }} + name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-role +subjects: + - kind: ServiceAccount + name: {{ include "command-cert-manager-issuer.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/deploy/charts/command-cert-manager-issuer/values.yaml b/deploy/charts/command-cert-manager-issuer/values.yaml index 4e8e7cc..c63b5c6 100644 --- a/deploy/charts/command-cert-manager-issuer/values.yaml +++ b/deploy/charts/command-cert-manager-issuer/values.yaml @@ -23,6 +23,15 @@ secretConfig: # namespace the chart is deployed in. useClusterRoleForSecretAccess: false + # If true, when using Issuer resources, the configmap resource must be created in the same namespace as the + # Issuer resource. This access is facilitated by granting the ServiceAccount [get, list, watch] for the config map + # API at the cluster level. + # + # If false, both Issuer and ClusterIssuer must reference a config map in the same namespace as the chart/reconciler. + # This access is facilitated by granting the ServiceAccount [get, list, watch] for the config map API only for the + # namespace the chart is deployed in. + useClusterRoleForConfigMapAccess: false + crd: # Specifies whether CRDs will be created create: true @@ -72,3 +81,10 @@ nodeSelector: {} tolerations: [] defaultHealthCheckInterval: "" + +env: {} + # This can be used to set an http proxy to access the Keyfactor instance + # - name: https_proxy + # value: http://someproxy:someport + # - name: no_proxy + # value: .somedomain.com,.local,10.0.0.1 diff --git a/docs/ambient-providers/google.md b/docs/ambient-providers/google.md new file mode 100644 index 0000000..407f6a0 --- /dev/null +++ b/docs/ambient-providers/google.md @@ -0,0 +1,447 @@ +# Ambient Credentials with Google Kubernetes Engine (GKE) + +> **IMPORTANT**: Support for adding Google as an identity provider in Command is only officially supported with Keyfactor Command 25.1.2+ and 25.2.1+. If you are on an older version of Command, please contact Keyfactor Customer Support for assistance on adding Google as an identity provider. + +This documentation covers the various ways to configure GKE workload identity for your workload to use ambient credentials with Keyfactor Command. Please refer to the official [Google documentation for workload identity federation](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) for the most up-to-date information regarding workload identity with GKE. For more information about what workload identity is and how it works in GKE, please refer [here](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity). + +## Authentication Options Overview + +GKE workloads can authenticate to external services like Keyfactor Command by obtaining ID tokens from the GKE metadata server. There are two approaches to configure this: + +1. **Workload Identity Federation for GKE with Service Account Impersonation** (Recommended) - Kubernetes ServiceAccounts are bound to Google Service Accounts, allowing fine-grained, per-workload identity management. The GKE metadata server uses the bound Google Service Account to generate ID tokens. +2. **Compute Engine Default Service Account** (Not recommended for production) - Workloads use a shared node-level service account; all workloads on the same node inherit these credentials with no isolation. + +This guide covers both approaches, but ***Workload Identity Federation for GKE with Service Account Impersonation is the recommended method*** for new deployments due to its improved security model and workload isolation. + +> **Important**: For the GKE metadata server to generate ID tokens, a Google Service Account must be available. In Option 1, you explicitly create and bind a GSA to your Kubernetes ServiceAccount. In Option 2, the Compute Engine default service account is used implicitly. + +> For more information on alternatives to Workload Identity Federation for GKE (and security compromises associated with these alternatives), please refer [to this list](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity). + +> For more information about service accounts in GKE, please refer to [this link](https://cloud.google.com/kubernetes-engine/docs/how-to/service-accounts). + +## Prerequisites + +Before configuring ambient credentials with GKE, ensure you have met the requirements [specified in Google's GKE guide](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) in addition to the following: + +- A GKE cluster (version 1.12 or later recommended; 1.24+ for all Workload Identity Federation features) +- `gcloud` CLI installed and authenticated +- `kubectl` configured to access your cluster +- Appropriate IAM permissions: + - `roles/container.admin` (for cluster configuration) + - `roles/iam.serviceAccountAdmin` (for service account management) + - `roles/iam.securityAdmin` (for IAM policy binding) +- Keyfactor Command 25.1.2+ or 25.2.1+ with Google OIDC provider configured ([how to configure](#configuring-google-as-identity-provider-in-keyfactor-command)) + +## GKE Identity Configuration Options + +### Option 1: Workload Identity Federation for GKE with Service Account Impersonation (Recommended) + +Workload Identity Federation for GKE with Service Account impersonation is the **most secure** method to grant your workloads the ability to obtain ID tokens for authentication. This approach: + +1. Creates a Google Service Account (GSA) specifically for your workload +2. Binds your Kubernetes ServiceAccount (KSA) to the GSA through IAM policy +3. Annotates the KSA to indicate which GSA to use +4. Allows the GKE metadata server to generate ID tokens using the GSA's identity + +#### Why Service Account Impersonation is Required + +The GKE metadata server endpoint (`metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity`) requires a Google Service Account to generate ID tokens. Without a GSA bound to your KSA: +- The metadata server has no identity to issue tokens for +- Token generation requests will fail with "service account not defined" errors +- Your workload cannot authenticate to external services + +The KSA annotation (`iam.gke.io/gcp-service-account`) tells the metadata server which GSA to use when generating tokens for pods using that KSA. + +#### Advantages +- **Better Security**: Fine-grained, per-workload identity without shared credentials +- **Workload Isolation**: Each workload can have its own dedicated GSA with specific permissions +- **Audit Trail**: Clear mapping between Kubernetes workloads and Google Service Accounts +- **Principle of Least Privilege**: Grant only the minimum required permissions to each workload + +#### Setup + +For the below steps, configure your environment variables. + +```bash +# Get project-level metadata +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ + --format="value(projectNumber)") + +export CLUSTER_NAME="cluster-name-here" # The name of your GKE cluster +export REGION="cluster-region" # The region your GKE cluster is deployed to (i.e. us-east1) + +export DEPLOYMENT_NAME="command-issuer" # The Helm chart deployment name +export KSA_NAMESPACE="command-issuer-system" # The namespace your command-cert-manager-issuer is deployed to (change if different than defined in root README) +export KSA_NAME="command-issuer" # This is the Kubernetes ServiceAccount that is automatically created when command-cert-manager-issuer is deployed with Helm +export GSA_NAME="command-cert-manager-issuer-gsa" # Google Service Account that will be created to grant the KSA permissions to assume its identity + +export NODEPOOL_NAME="gke-wi-nodepool" # The nodepool that will have the GKE metadata server enabled on it +``` + +#### Step 1: Enable Workload Identity Federation on Your Cluster + +For **existing clusters**, enable Workload Identity Federation: + +```bash +# Enable Workload Identity Federation on the cluster +gcloud container clusters update ${CLUSTER_NAME} \ +--location=${REGION} \ +--workload-pool=${PROJECT_ID}.svc.id.goog +``` + +For **new clusters**, create with Workload Identity Federation enabled: + +```bash +# Create cluster with Workload Identity Federation +gcloud container clusters create ${CLUSTER_NAME} \ +--region=${REGION} \ +--workload-pool=${PROJECT_ID}.svc.id.goog +``` + +> **Note**: If your cluster was created after May 30, 2024 (Standard) or June 18, 2024 (Autopilot), Workload Identity is enabled by default. You can verify this with: +> ```bash +> gcloud container clusters describe ${CLUSTER_NAME} \ +> --location=${REGION} \ +> --format="value(workloadIdentityConfig.workloadPool)" +> ``` + +#### Step 2: Configure Node Pools (if needed) + +Check if your node pools have the GKE metadata server enabled: + +```bash +# Check the workload metadata configuration +gcloud container node-pools describe \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(config.workloadMetadataConfig.mode)" +``` + +If the output is `GKE_METADATA`, you can skip this step. If it's `GCE_METADATA` or empty, create a new node pool or update existing pools: + +```bash +# Option A: Create a new node pool with GKE_METADATA +gcloud container node-pools create ${NODEPOOL_NAME} \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA + +# Option B: Update existing node pool (requires recreation of nodes) +gcloud container node-pools update \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA +``` + +> **Note**: Clusters created after the dates mentioned in Step 1 have `GKE_METADATA` enabled by default on all node pools. + +#### Step 3: Create Google Service Account + +Create a Google Service Account that will be used to generate ID tokens: + +```bash +# Create the Google Service Account +gcloud iam service-accounts create ${GSA_NAME} \ + --display-name="command-cert-manager-issuer Service Account" \ + --project=${PROJECT_ID} +``` + +> **Important**: This GSA doesn't need any GCP API permissions unless your workload needs to access other Google Cloud services. For ID token generation alone, the service account just needs to exist. + +#### Step 4: Create Kubernetes Namespace and ServiceAccount + +```bash +# Get cluster credentials +gcloud container clusters get-credentials ${CLUSTER_NAME} \ + --region=${REGION} + +# Create namespace if it doesn't already exist +kubectl create namespace ${KSA_NAMESPACE} 2>/dev/null || true + +# Create Kubernetes ServiceAccount if it doesn't already exist +kubectl create serviceaccount ${KSA_NAME} \ + --namespace=${KSA_NAMESPACE} 2>/dev/null || true +``` + +#### Step 5: Create Workload Identity Binding + +Bind the Kubernetes ServiceAccount to the Google Service Account, allowing the KSA to impersonate the GSA: + +```bash +# Allow the KSA to impersonate the GSA +gcloud iam service-accounts add-iam-policy-binding ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \ + --role roles/iam.workloadIdentityUser \ + --member "serviceAccount:${PROJECT_ID}.svc.id.goog[${KSA_NAMESPACE}/${KSA_NAME}]" +``` + +This grants the `roles/iam.workloadIdentityUser` role to the Kubernetes ServiceAccount, allowing it to act as the Google Service Account. + +#### Step 6: Annotate Kubernetes ServiceAccount + +Annotate the KSA to specify which GSA it should use: + +```bash +# Annotate the KSA with the GSA email +kubectl annotate serviceaccount ${KSA_NAME} \ + --namespace ${KSA_NAMESPACE} \ + iam.gke.io/gcp-service-account=${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com +``` + +This annotation is **critical** - it tells the GKE metadata server which Google Service Account to use when generating ID tokens for pods using this KSA. + +#### Step 7: Update Workload to Use GKE Metadata Server Nodes (if needed) + +If you created a new node pool with `GKE_METADATA` enabled, update your deployment to schedule pods on those nodes: + +If `command-cert-manager-issuer` was deployed using Helm: + +```bash +helm upgrade ${DEPLOYMENT_NAME} deploy/charts/command-cert-manager-issuer \ + --namespace ${KSA_NAMESPACE} \ + --reuse-values \ + --set-string "nodeSelector.iam\.gke\.io/gke-metadata-server-enabled=true" +``` + +If deployed without Helm, edit the Deployment directly: + +```bash +kubectl edit deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} +``` + +Add the nodeSelector under `spec.template.spec`: + +```yaml +spec: + template: + spec: + nodeSelector: + iam.gke.io/gke-metadata-server-enabled: "true" +``` + +Then restart the deployment: + +```bash +kubectl rollout restart deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} +``` + +> **Note**: If all your node pools have `GKE_METADATA` enabled, you can skip the nodeSelector configuration. + +#### Step 8: Retrieve Identity Information for Keyfactor Command + +Get the OAuth Client ID (unique ID) of the Google Service Account: + +```bash +gcloud iam service-accounts describe ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \ + --format="value(oauth2ClientId)" +``` + +This ID will be used to create a security claim in Keyfactor Command for your identity provider. + +--- + +### Option 2: Compute Engine Default Service Account (Not Recommended for Production) + +> **SECURITY WARNING**: All pods on the same node share the same service account, which violates the principle of least privilege. This approach is provided for reference only and is **strongly discouraged** for production use. + +When creating a GKE cluster without specifying a custom service account, nodes automatically use the Compute Engine [default service account](https://cloud.google.com/compute/docs/access/service-accounts#token) (`-compute@developer.gserviceaccount.com`). This service account can be used by the GKE metadata server to generate ID tokens. + +#### Security Concerns + +- By default, the Compute Engine service account has the Editor role, which is overly permissive +- All pods on the same node share this identity with no isolation +- No per-workload credential management +- Violates the principle of least privilege +- Increases blast radius in case of pod compromise +- Cannot distinguish between different workloads in audit logs + +**For production environments, use Option 1 instead.** + +For the below steps, configure your environment variables: + +```bash +# Get project-level metadata +export PROJECT_ID=$(gcloud config get project) # use "gcloud projects list" to get a list of projects and "gcloud config set project " to set the project +export PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} \ + --format="value(projectNumber)") + +export CLUSTER_NAME="cluster-name-here" # The name of your GKE cluster +export REGION="cluster-region" # The region your GKE cluster is deployed to (i.e. us-east1) +``` + +#### Step 1: Check Current Configuration + +Verify that your cluster is using the default node service account: + +```bash +# Check if Workload Identity Federation is enabled +gcloud container clusters describe ${CLUSTER_NAME} \ + --region=${REGION} \ + --format="value(workloadIdentityConfig.workloadPool)" + +# If empty, Workload Identity Federation is NOT enabled + +# Check node pool service account +gcloud container node-pools describe default-pool \ + --cluster=${CLUSTER_NAME} \ + --region=${REGION} \ + --format="value(config.serviceAccount)" + +# If "default", you're using the Compute Engine default service account +``` + +#### Step 2: Retrieve Identity Information + +Get the OAuth Client ID (unique ID) of the Compute Engine default service account: + +```bash +# Get the unique ID (sub claim) +gcloud iam service-accounts describe \ + ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com \ + --format='value(oauth2ClientId)' +``` + +This ID will be used to create a security claim in Keyfactor Command for your identity provider. + +## Configuring Google as Identity Provider in Keyfactor Command + +After configuring your GKE workload identity, you need to set up Google as an identity provider in Keyfactor Command. + +### Step 1: Navigate to Identity Providers + +1. Log in to Keyfactor Command +2. Navigate to **Settings** > **Identity Providers** +3. Click **Add** + +### Step 2: Import Discovery Document + +Use Google's standard OIDC discovery endpoint: + +``` +https://accounts.google.com/.well-known/openid-configuration +``` + +This endpoint provides the necessary configuration for Google's identity provider, including the issuer URL, token endpoints, and supported claims. + +### Step 3: Configure Claim Mappings + +Configure the following claim mappings: + +- **Name Claim Type** (OAuth Subject): `sub` +- **Unique Claim Type** (OAuth Object ID): `azp` (or `sub`, depending on your token format) +- **Display Name**: Google GKE (or your preferred name) + +> **Note**: For programmatic API access, Command requires you to fill in Client ID and Client Secret fields, but these values are not actually used for workload identity authentication. You can use any placeholder values for these fields. + +### Step 4: Save and Test + +1. Click **Save** to create the identity provider +2. Test the configuration by retrieving a token from your workload +3. Verify the token is accepted by Keyfactor Command + +### Step 5: Map Identity to Security Roles + +After saving the identity provider: + +1. Navigate to **Security** > **Security Roles** +2. Select or create a security role for your workload +3. Add a security claim with the appropriate identifier: + - For **Option 1 (Workload Identity with SA impersonation)**: Use the OAuth Client ID of your Google Service Account (from Step 8 above) + - For **Option 2 (Compute Engine default SA)**: Use the OAuth Client ID of the Compute Engine default service account +4. Configure the appropriate permissions for certificate operations + +The security claim format in Command should be: +- **Claim Type**: OAuth Subject (or similar, depending on your token's `sub` claim) +- **Claim Value**: The numeric OAuth Client ID retrieved in the setup steps + +--- + +## Troubleshooting + +### Common Issues + +> For any issues not covered below, check out the [root README's troubleshooting](../../README.md#troubleshooting) section. + +#### Issue: "metadata: GCE metadata 'instance/service-accounts/default/identity' not defined" + +**Cause**: The KSA annotation is missing or incorrect, or the workload identity binding is not configured + +**Solution**: +1. Verify the KSA annotation exists: + ```bash + kubectl get serviceaccount ${KSA_NAME} -n ${KSA_NAMESPACE} -o yaml | grep iam.gke.io/gcp-service-account + ``` +2. Verify the workload identity binding: + ```bash + gcloud iam service-accounts get-iam-policy ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com + ``` +3. Ensure pods are restarted after adding the annotation: + ```bash + kubectl rollout restart deployment ${DEPLOYMENT_NAME} -n ${KSA_NAMESPACE} + ``` + +#### Issue: "Permission denied" errors + +**Cause**: IAM permissions not correctly configured + +**Solution**: +- Verify the workload identity binding is correct: + ```bash + gcloud iam service-accounts get-iam-policy ${GSA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com + ``` +- Ensure the binding includes `roles/iam.workloadIdentityUser` for the correct KSA +- Check that the workload pool is correctly configured on the cluster + +#### Issue: "Invalid token" from Keyfactor Command + +**Cause**: Issuer URL mismatch or incorrect claim mapping + +**Solution**: +- Verify the issuer URL in Keyfactor matches the token's `iss` claim (`https://accounts.google.com`) +- Check that the security claim in Keyfactor Command matches the token's `sub` claim (should be the OAuth Client ID) +- Ensure the token audience matches what Keyfactor Command expects +- Verify the identity provider discovery document was imported correctly + +#### Issue: Pod cannot authenticate / Workload Identity not working + +**Cause**: Workload Identity not enabled on cluster or node pool metadata incorrect + +**Solution**: +```bash +# Verify Workload Identity is enabled on cluster +gcloud container clusters describe ${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(workloadIdentityConfig.workloadPool)" + +# Should output: .svc.id.goog + +# Check node pool metadata configuration +gcloud container node-pools describe \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --format="value(config.workloadMetadataConfig.mode)" + +# Should output: GKE_METADATA + +# If not correct, update the cluster: +gcloud container clusters update ${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-pool=${PROJECT_ID}.svc.id.goog + +# And update/create node pool: +gcloud container node-pools create ${NODEPOOL_NAME} \ + --cluster=${CLUSTER_NAME} \ + --location=${REGION} \ + --workload-metadata=GKE_METADATA +``` + +--- + +## Additional Resources + +- [Official GKE Workload Identity Documentation](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) +- [Workload Identity Federation Concepts](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity) +- [Supported Products and Limitations](https://cloud.google.com/iam/docs/federated-identity-supported-services) +- [Keyfactor Command Identity Provider Documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/IdentityProviderOperations.htm) +- [Google Service Account Documentation](https://cloud.google.com/iam/docs/service-account-overview) +- [Best Practices for GKE Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity#best_practices) diff --git a/docs/ca-bundle/README.md b/docs/ca-bundle/README.md new file mode 100644 index 0000000..6492a53 --- /dev/null +++ b/docs/ca-bundle/README.md @@ -0,0 +1,330 @@ +# CA Bundle + +The command-cert-manager-issuer integration requires a secure, trusted connection with the targeted Keyfactor Command instance. + +## Using Self-Signed Certificates + +If the targeted Keyfactor Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate **must be provided** via a Kubernetes Secret of ConfigMap. The secret must belong to the same namespace that command-cert-manager-issuer is deployed to (i.e. `command-issuer-system`). + +```shell +kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt + +kubectl -n command-issuer-system create configmap command-ca --from-file=ca.crt +``` + +In the Issuer / ClusterIssuer specification, reference the created resource. + +```yaml + apiVersion: command-issuer.keyfactor.com/v1alpha1 + kind: Issuer + metadata: + name: issuer-sample + namespace: default + spec: + ... + caSecretName: "command-ca-secret" # if using Kubernetes Secret + caBundleConfigMapName: "command-ca" # if using Kubernetes ConfigMap + caBundleKey: "ca.crt" # optional key name, pulls the last key in resource if not specified +``` + +## Using Publicly Trusted Certificates + +If the targeted Keyfactor Command API is configured with a publicly trusted certificate authority (Sectigo / LetsEncrypt / etc.), the command-cert-manager-issuer container image is built with a pre-bundled trust store of publicly trusted certificates but with a ***very important caveat***. The trust store may become out-of-sync over time, especially if the certificate authority issuing the Keyfactor Command certificate is updated. + +It is **not required** to use the `caSecretName` / `caBundleConfigMapName` specification if Keyfactor Command's TLS certificate is built using a publicly trusted root, but it is **recommended for production workloads to maintain a list of trusted certificates** instead of relying on the pre-bundled certificate store when the command-cert-manager-issuer image is created. This will reduce the likelihood of connectivity issues if the Keyfactor Command instance is updated to use a new CA or if the command-cert-manager-issuer image is updated and it does not include the Keyfactor Command TLS certificate's root CA in its trust store. + +This document covers available tools to help manage CA trust bundles. + +### trust-manager + +[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can be used to sync CA trust bundles in a Kubernetes cluster. trust-manager can synchronize a list of publicly trusted CAs as well as any custom CAs to be included in the trust chain. It is recommended to add your Keyfactor Command's intermediate and root CAs to a Kubernetes Secret / ConfigMap and synchronize this with the trust-manager bundle. + +The publicly trusted certificates are tied to the trust-manager image. To pull up-to-date publicly trusted CAs, update the trust-manager deployment to the latest version. + +trust-manager can synchronize the CA trust bundle to either a Kubernetes Secret or ConfigMap, this documentation will cover both methods. + +> NOTE: For the latest documentation and installation instructions, please refer to the [cert-manager trust-manager documentation](https://cert-manager.io/docs/trust/trust-manager/installation/). The instructions below may become outdated over time. + +#### Pre-requisites + +- cert-manager is already installed in the Kubernetes cluster +- a namespace is already created where trust-manager will sync CA bundles to (i.e. command-issuer-system) + +#### Security Considerations + +> ⚠️ Important: Required Permissions. Please Read! + +trust-manager requires different permission scopes depending on your synchronization target: + +**Synchronizing to ConfigMaps (Recommended):** +- ✅ Only requires cluster-wide **read** access to ConfigMaps +- ✅ Lower security risk +- ✅ Suitable for most environments + +**Synchronizing to Secrets:** +- ⚠️ Requires cluster-wide **read** access to **all Secrets** +- ⚠️ Higher security risk - trust-manager can read any secret in the cluster +- ⚠️ Requires explicit RBAC configuration (shown below) +- ⚠️ Only use if you have specific requirements for Secret storage + +**Permission Summary:** + +| Target Type | Read Scope | Write Scope | Security Impact | +|-------------|----------------|--------------------|-----------------| +| ConfigMap | ConfigMaps | Namespace-specific | Low | +| Secret | **All Secrets**| Namespace-specific | High | + +For most deployments, **Option 1 (ConfigMap)** is recommended unless you have compliance requirements mandating Secret storage. + +#### Option 1: Synchronizing to a ConfigMap + +##### Setting up trust-manager + +1. Install trust-manager + + ```bash + # Install trust-manager in the cert-manager namespace + helm install trust-manager oci://quay.io/jetstack/charts/trust-manager \ + --namespace cert-manager \ + --create-namespace \ + --wait + ``` +2. Create a ConfigMap from a PEM file + + Create a ConfigMap containing the PEM of the CA certificates you want to trust. Create the ConfigMap in the same namespace trust-manager is deployed to. + + ```bash + kubectl create configmap enterprise-root-ca \ + --from-file=ca.crt=/path/to/root-ca.pem \ + --namespace=cert-manager \ + --dry-run=client -o yaml | kubectl apply -f - + ``` + +3. Label target namespaces + + Label the namespace command-cert-manager-issuer is deployed to annotate trust-manager should write ConfigMaps to it + + ```bash + kubectl label namespace command-issuer-system command-issuer-ca-bundle=enabled # change to your namespace + ``` + +4. Create a Bundle + + Create a bundle resource to tell trust-manager what ConfigMaps to synchronize and whether to include publicly trusted CAs as part of the sync. + + ```yaml + kubectl apply -f - < 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) + You can also install a specific version of the command-cert-manager-issuer Helm chart: + + ```shell + helm search repo command-issuer/command-cert-manager-issuer --versions + ``` + + ```shell + helm install command-cert-manager-issuer command-issuer/command-cert-manager-issuer \ + --namespace command-issuer-system \ + --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) > 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) + # Authentication ## Explicit Credentials @@ -134,6 +149,7 @@ These credentials must be configured using a Kubernetes Secret. By default, the Command Issuer also supports ambient authentication, where a token is fetched from an Authorization Server using a cloud provider's auth infrastructure and passed to Command directly. The following methods are supported: - [Managed Identity Using Azure Entra ID Workload Identity](./docs/ambient-providers/azure.md) (if running in [AKS](https://azure.microsoft.com/en-us/products/kubernetes-service)) +- [Workload Identity Using Google Kubernetes Engine](./docs/ambient-providers/google.md) (if running in [GKE](https://cloud.google.com/kubernetes-engine)) If you are running your Kubernetes workload in a cloud provider not listed above, you can use workload identity federation with [Azure AD](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation). @@ -180,11 +196,7 @@ This section has moved. Please refer to [this link](./docs/ambient-providers/azu # CA Bundle -If the Command API is configured to use a self-signed certificate or with a certificate whose issuer isn't widely trusted, the CA certificate must be provided as a Kubernetes secret. - -```shell -kubectl -n command-issuer-system create secret generic command-ca-secret --from-file=ca.crt -``` +This section has been moved. Please refer to the new [CA Bundle docs](./docs/ca-bundle/README.md) documentation regarding CA trust with command-cert-manager-issuer. # Creating Issuer and ClusterIssuer resources @@ -211,7 +223,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou | hostname | The hostname of the Command API Server. | | apiPath | (optional) The base path of the Command REST API. Defaults to `KeyfactorAPI`. | | commandSecretName | (optional) The name of the Kubernetes secret containing basic auth credentials or OAuth 2.0 credentials. Omit if using ambient credentials. | - | caSecretName | (optional) The name of the Kubernetes secret containing the CA certificate. Required if the Command API uses a self-signed certificate or it was signed by a CA that is not widely trusted. | + | 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` | | 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. | @@ -244,7 +258,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # 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" @@ -277,7 +293,9 @@ For example, ClusterIssuer resources can be used to issue certificates for resou hostname: "$HOSTNAME" apiPath: "/KeyfactorAPI" # Preceding & trailing slashes are handled automatically commandSecretName: "command-secret" # references the secret created above. Omit if using ambient credentials. - caSecretName: "command-ca-secret" # references the secret created above + # caSecretName: "command-ca-secret" # references a secret containing the CA trust chain (see CA Bundle docs for more info) + # caBundleConfigMapName: "command-ca-configmap" # references a configmap containing the CA trust chain (see CA Bundle docs for more info) + # 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" diff --git a/e2e/.env.example b/e2e/.env.example index 9dea707..abe4f8a 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -8,5 +8,8 @@ export CERTIFICATE_AUTHORITY_LOGICAL_NAME="Sub-CA" export OAUTH_TOKEN_URL="https://example.com/oauth2/token" export OAUTH_CLIENT_ID="changeme" export OAUTH_CLIENT_SECRET='changeme' + +export DISABLE_CA_CHECK="false" # Set to true to disable CA check in tests + export OAUTH_SCOPES='optional' # remove if not needed -export OAUTH_AUDIENCE='optional' # remove if not needed \ No newline at end of file +export OAUTH_AUDIENCE='optional' # remove if not needed diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..0caca0b --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +certs/* +!**/.gitkeep \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index 9a0262c..48b81b4 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,8 +12,6 @@ The test suite does the following: This is currently configured as a Bash script, so it is necessary to run this on a UNIX-compatible machine. -Instructions on how to run the e2e test suite are within the [run_tests.sh](./run_tests.sh) file. - ## Requirements - An available Command instance is running and configured as described in the [root README](../README.md#configuring-command) - OAuth is used to communicate with Command @@ -23,6 +21,10 @@ Instructions on how to run the e2e test suite are within the [run_tests.sh](./ru - helm (>= v3.17.1) - cmctl (>= v2.1.1) +On the Command side: +- An enrollment pattern is created called "Test Enrollment Pattern" that is has CSR Enrollment, CSR Generation, and PFX Enrollment enabled +- A security role by the name of "InstanceOwner" exists and has the ability to perform Enrollment + ## Configuring the environment variables command-cert-manager-issuer interacts with an external Command instance. An environment variable file `.env` can be used to store the environment variables to be used to talk to the Command instance. @@ -35,6 +37,13 @@ cp .env.example .env Modify the fields as needed. +## Configuring the trusted certificate store +The issuer created in the end-to-end tests can leverage the `caSecretName` specification to determine a collection of CAs to trust in order to establish a trusted connection with the remote Keyfactor Command instance. The certificates defined in this secret will be pulled from the `certs` folder in this directory. + +Please place the CA certificates for the Keyfactor Command instance you'd like to connect to (the intermediate and/or root CAs) under `certs` directory. + +> NOTE: This check can be disabled by setting the env variable `DISABLE_CA_CHECK=true`. + ## Running the script ```bash diff --git a/e2e/certs/.gitkeep b/e2e/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 7541d35..1d3ba30 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -1,7 +1,7 @@ #!/bin/bash ## ======================= LICENSE =================================== -# 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. @@ -73,11 +73,14 @@ CERT_MANAGER_NAMESPACE="cert-manager" ISSUER_NAMESPACE="issuer-playground" SIGNER_SECRET_NAME="auth-secret" -SIGNER_CA_SECRET_NAME="ca-secret" CERTIFICATE_CRD_FQTN="certificates.cert-manager.io" CERTIFICATEREQUEST_CRD_FQTN="certificaterequests.cert-manager.io" +CA_CERTS_PATH="e2e/certs" +SIGNER_CA_SECRET_NAME="ca-trust-secret" +SIGNER_CA_CONFIGMAP_NAME="ca-trust-configmap" + CR_C_NAME="command-cert" CR_CR_NAME="command-cert-1" CR_C_SECRET_NAME="$CR_C_NAME-tls" @@ -113,6 +116,7 @@ check_env() { validate_env_present OAUTH_SCOPES false validate_env_present CERTIFICATE_AUTHORITY_HOSTNAME false + validate_env_present DISABLE_CA_CHECK false } # checks whether the provided kubernetes namespace exists @@ -370,6 +374,15 @@ create_issuer() { return 1 fi + regenerate_ca_secret + regenerate_ca_config_map + + caSecretNameSpec="caSecretName: $SIGNER_CA_SECRET_NAME" + if [[ "$DISABLE_CA_CHECK" == "true" ]]; then + echo "⚠️ Disabling CA check as per DISABLE_CA_CHECK environment variable" + caSecretNameSpec="" + fi + kubectl -n "$ISSUER_NAMESPACE" apply -f - </dev/null | grep -v '.gitkeep')" ]; then + echo "✅ Certificates found in $CA_CERTS_PATH directory." + return 0 + fi + + echo "⚠️ No certificates found in $CA_CERTS_PATH directory. May result in test failures." +} + +create_ca_secret () { + echo "🔐 Creating CA secret resource..." + + check_for_certificates + + kubectl -n ${MANAGER_NAMESPACE} create secret generic $SIGNER_CA_SECRET_NAME --from-literal=ca.crt="$( + find e2e/certs -type f ! -name '.gitignore' -exec cat {} \; + )" \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "✅ CA secret '$SIGNER_CA_SECRET_NAME' created successfully" +} + +delete_ca_secret() { + echo "🗑️ Deleting CA secret..." + + kubectl -n ${MANAGER_NAMESPACE} delete secret $SIGNER_CA_SECRET_NAME || true + + echo "✅ CA secret '$SIGNER_CA_SECRET_NAME' deleted successfully" +} + +regenerate_ca_secret() { + echo "🔄 Regenerating CA secret..." + + delete_ca_secret + create_ca_secret + + echo "✅ CA secret regenerated successfully" +} + +add_bad_cert_to_ca_secret() { + echo "🔐 Adding bad certificate to CA secret..." + + kubectl -n ${MANAGER_NAMESPACE} patch secret $SIGNER_CA_SECRET_NAME\ + --type='json' \ + -p='[ + { + "op": "add", + "path": "/data/zzz.crt", + "value": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClRISVNfSVNfTk9UX0FfUkVBTF9DRVJUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ]' + + echo "✅ Bad certificate added to CA secret successfully." +} + +create_ca_config_map() { + echo "🔐 Creating CA config map resource..." + + check_for_certificates + + kubectl -n ${MANAGER_NAMESPACE} create configmap $SIGNER_CA_CONFIGMAP_NAME --from-literal=ca.crt="$( + find e2e/certs -type f ! -name '.gitignore' -exec cat {} \; + )" \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "✅ CA config map '$SIGNER_CA_CONFIGMAP_NAME' created successfully" +} + +delete_ca_config_map() { + echo "🗑️ Deleting CA config map..." + + kubectl -n ${MANAGER_NAMESPACE} delete configmap $SIGNER_CA_CONFIGMAP_NAME || true + + echo "✅ CA config map '$SIGNER_CA_CONFIGMAP_NAME' deleted successfully" +} + +regenerate_ca_config_map() { + echo "🔄 Regenerating CA config map..." + + delete_ca_config_map + create_ca_config_map + + echo "✅ CA config map regenerated successfully" +} + +add_bad_cert_to_ca_config_map() { + echo "🔐 Adding bad certificate to CA config map..." + + kubectl -n ${MANAGER_NAMESPACE} patch configmap $SIGNER_CA_CONFIGMAP_NAME\ + --type='json' \ + -p='[ + { + "op": "add", + "path": "/data/zzz.crt", + "value": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClRISVNfSVNfTk9UX0FfUkVBTF9DRVJUCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ]' + + echo "✅ Bad certificate added to CA config map successfully." +} # ================= BEGIN: Resource Deployment ===================== @@ -817,6 +942,11 @@ echo "" delete_certificate_request echo "" +echo """🔐 Creating CA secret used for testing..." +regenerate_ca_secret +regenerate_ca_config_map +echo "" + # Deploy Issuer echo "🔐 Deploying $ISSUER_NAMESPACE namespace if not exists..." kubectl create namespace ${ISSUER_NAMESPACE} --dry-run=client -o yaml | kubectl apply -f - @@ -1005,8 +1135,106 @@ check_certificate_request_status echo "🧪✅ Test 104 completed successfully." echo "" +## =================== END: Annotation Tests ============================ + +## =================== BEGIN: CA Secret / ConfigMap Tests ============================ + +if [[ "$DISABLE_CA_CHECK" == "true" ]]; then + echo "⚠️ Skipping CA Secret / ConfigMap Tests as DISABLE_CA_CHECK is set to true" +else + echo "🧪💬 Test 200: Use Secret for CA Bundle" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 200 completed successfully." + echo "" + + echo "🧪💬 Test 200a: Use Secret for CA Bundle ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 200a completed successfully." + echo "" + + echo "🧪💬 Test 201: Use ConfigMap for CA Bundle" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 201 completed successfully." + echo "" + + echo "🧪💬 Test 201a: Use ConfigMap for CA Bundle ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 201a completed successfully." + echo "" + + echo "🧪💬 Test 202: Use Secret with CA Key" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + add_bad_cert_to_ca_secret + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" Issuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 202 completed successfully." + echo "" + + echo "🧪💬 Test 202a: Use Secret with CA Key ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_bad_cert_to_ca_secret + add_issuer_specification_field caSecretName "\"$SIGNER_CA_SECRET_NAME\"" ClusterIssuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 202a completed successfully." + echo "" + + echo "🧪💬 Test 203: Use ConfigMap with CA Key" + regenerate_issuer + delete_issuer_specification_field caSecretName Issuer + add_bad_cert_to_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" Issuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" Issuer + regenerate_certificate_request Issuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 203 completed successfully." + echo "" + + echo "🧪💬 Test 203a: Use ConfigMap with CA Key ClusterIssuer" + regenerate_cluster_issuer + delete_issuer_specification_field caSecretName ClusterIssuer + add_bad_cert_to_ca_config_map + add_issuer_specification_field caBundleConfigMapName "\"$SIGNER_CA_CONFIGMAP_NAME\"" ClusterIssuer + add_issuer_specification_field caBundleKey "\"ca.crt\"" ClusterIssuer + regenerate_certificate_request ClusterIssuer + approve_certificate_request + check_certificate_request_status + echo "🧪✅ Test 203a completed successfully." + echo "" +fi + + + echo "🎉🎉🎉 Tests have completed successfully!" -## =================== END: Annotation Tests ============================ +## =================== END: CA Secret / ConfigMap Tests ============================ # ================= END: Test Execution ======================== \ No newline at end of file diff --git a/internal/command/client.go b/internal/command/client.go index e6d1ca0..36616e3 100644 --- a/internal/command/client.go +++ b/internal/command/client.go @@ -20,6 +20,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -107,6 +108,10 @@ type azure struct { func (a *azure) GetAccessToken(ctx context.Context) (string, error) { log := log.FromContext(ctx) + // Try Azure with a short timeout + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + // To prevent clogging logs every time JWT is generated initializing := a.cred == nil @@ -122,7 +127,7 @@ func (a *azure) GetAccessToken(ctx context.Context) (string, error) { log.Info(fmt.Sprintf("generating Default Azure Credentials with scopes %s", strings.Join(a.scopes, " "))) // Request a token with the provided scopes - token, err := a.cred.GetToken(ctx, policy.TokenRequestOptions{ + token, err := a.cred.GetToken(timeoutCtx, policy.TokenRequestOptions{ Scopes: a.scopes, }) if err != nil { diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index 262cc95..aabbb2a 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -40,7 +40,9 @@ const ( var ( errGetAuthSecret = errors.New("failed to get Secret containing Issuer credentials") + errGetCaConfigMap = errors.New("caBundleConfigMapName specified a name, but failed to get ConfigMap containing CA certificate") errGetCaSecret = errors.New("caSecretName specified a name, but failed to get Secret containing CA certificate") + errGetCaBundleKey = errors.New("failed to get CA bundle key from CA certificate data") errHealthCheckerBuilder = errors.New("failed to build the healthchecker") errHealthCheckerCheck = errors.New("healthcheck failed") ) @@ -254,24 +256,51 @@ func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer comman } } - var caSecret corev1.Secret - // If the CA secret name is not specified, we will not attempt to retrieve it - if issuer.GetSpec().CaSecretName != "" { + var caData map[string][]byte + + if issuer.GetSpec().CaBundleConfigMapName != "" { + var configMap corev1.ConfigMap + err := c.Get(ctx, types.NamespacedName{ + Name: issuer.GetSpec().CaBundleConfigMapName, + Namespace: secretNamespace, + }, &configMap) + + if err != nil { + return nil, fmt.Errorf("%w, configmap name: %s, reason: %w", errGetCaConfigMap, issuer.GetSpec().CaBundleConfigMapName, err) + } + + caData = make(map[string][]byte) + for key, value := range configMap.Data { + caData[key] = []byte(value) + } + } else if issuer.GetSpec().CaSecretName != "" { + var caSecret corev1.Secret + err := c.Get(ctx, types.NamespacedName{ Name: issuer.GetSpec().CaSecretName, Namespace: secretNamespace, }, &caSecret) + if err != nil { return nil, fmt.Errorf("%w, secret name: %s, reason: %w", errGetCaSecret, issuer.GetSpec().CaSecretName, err) } + + caData = caSecret.Data } var caCertBytes []byte - // There is no requirement that the CA certificate is stored under a specific - // key in the secret, so we can just iterate over the map and effectively select - // the last value in the map - for _, bytes := range caSecret.Data { - caCertBytes = bytes + + if issuer.GetSpec().CaBundleKey != "" { + caCert, ok := caData[issuer.GetSpec().CaBundleKey] + if !ok { + return nil, fmt.Errorf("%w: caBundleKey '%s' not found in CA bundle data", errGetCaBundleKey, issuer.GetSpec().CaBundleKey) + } + caCertBytes = caCert + } else { + // If no caBundleKey is specified, take the last entry in the caData map + for _, bytes := range caData { + caCertBytes = bytes + } } return &command.Config{ diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 7ccccbc..9e6764d 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_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. @@ -23,6 +23,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + commandissuer "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" commandissuerv1alpha1 "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" logrtesting "github.com/go-logr/logr/testing" @@ -955,3 +956,669 @@ func TestIssuerReconcile(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 f73a0f82c7b94b6f3e690b59fde9a3b4774609ee Mon Sep 17 00:00:00 2001 From: spb <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:15:20 -0700 Subject: [PATCH 2/4] release: 2.5.1 * feat: release 2.5.0 (#62) 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation Co-authored-by: Matthew H. Irby * feat: add client caching to reduce OAuth token requests Previously, every certificate request reconciliation created a new Command API client, which meant a new OAuth token was fetched for each request. For customers with OAuth provider quotas, this caused rate limiting issues. This change introduces a ClientCache that: - Caches Command API clients by configuration hash - Reuses cached clients across reconciliations for the same issuer - Allows the underlying oauth2 library's token caching to work as intended - Is thread-safe for concurrent reconciliations The cache key is a SHA-256 hash of all configuration fields that affect the client connection (hostname, API path, credentials, scopes, etc.), ensuring different issuers get different clients while the same issuer reuses its client. Fixes: OAuth token re-authentication on every request Co-Authored-By: Claude Opus 4.5 * chore(scripts): update scripting usability * feat: update keyfactor-auth-client-go to v1.3.1 Signed-off-by: Matthew H. Irby * chore: remove test short circuit Signed-off-by: Matthew H. Irby * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Revert "Potential fix for pull request finding" This reverts commit 19bc19be8aa93166d4e628c5eb6bf788ea3dd797. * chore: cleanup Signed-off-by: Matthew H. Irby * chore: break build & test into its own workflow Signed-off-by: Matthew H. Irby * fix: remove lint from CI Signed-off-by: Matthew H. Irby * chore(docs): update CHANGELOG Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Matthew H. Irby Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew H. Irby Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../keyfactor-bootstrap-workflow.yml | 42 ---- .github/workflows/test.yml | 62 ++++++ CHANGELOG.md | 4 + Dockerfile | 2 + Makefile | 7 +- cmd/main.go | 13 +- e2e/.gitignore | 1 + e2e/README.md | 95 ++++++++- e2e/run_tests.sh | 60 ++++-- go.mod | 6 +- go.sum | 4 +- internal/command/client_cache.go | 183 ++++++++++++++++ internal/command/client_cache_test.go | 198 ++++++++++++++++++ 13 files changed, 590 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 internal/command/client_cache.go create mode 100644 internal/command/client_cache_test.go diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index b4fff0e..41f8bf4 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -10,50 +10,8 @@ on: - 'release-*.*' jobs: - - build: - name: Build and Check CRDs - runs-on: ubuntu-latest - timeout-minutes: 8 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4.2.1 - with: - go-version-file: 'go.mod' - cache: true - - run: go mod download - - run: go build -v ./cmd/main.go - - name: Regenerate CRDs - run: make generate manifests - - name: Check for CRD drift - run: | - git diff --compact-summary --exit-code || \ - (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) - # - name: Run linters - # uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 - # with: - # version: latest - - test: - name: Go Test - needs: build - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Go 1.x - uses: actions/setup-go@v4.2.1 - with: - go-version-file: 'go.mod' - cache: true - - run: go mod download - - name: Run go test - run: go test -v ./... - call-starter-workflow: uses: keyfactor/actions/.github/workflows/starter.yml@3.2.0 - needs: test secrets: token: ${{ secrets.V2BUILDTOKEN}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a0f0f80 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,62 @@ +name: Build and Test +on: + pull_request: + push: + branches: + - 'main' + - 'release-*.*' +jobs: + build: + name: Build and Lint + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + # Checkout code + # https://github.com/actions/checkout + - name: Checkout code + uses: actions/checkout@v5 + + # Setup GoLang build environment + # https://github.com/actions/setup-go + - name: Set up Go 1.x + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + cache: true + + # Download dependencies + - run: go mod download + + # Build Go binary + - run: go build -v cmd/main.go + + - name: Regenerate CRDs + run: make generate manifests + + - name: Check for CRD drift + run: | + git diff --compact-summary --exit-code || \ + (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) + + test: + name: Go Test + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Checkout code + # https://github.com/actions/checkout + - name: Checkout code + uses: actions/checkout@v5 + + # Setup GoLang build environment + # https://github.com/actions/setup-go + - name: Set up Go 1.x + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + cache: true + + # Run Go tests + - name: Run go test + run: go test -v ./... \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c78ea9d..551abd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v2.5.1 +## Fixes +- Fixes an issue where OAuth 2.0 client credentials were being regenerated on every API call. + # v2.5.0 ## Features - Add support to specify a ConfigMap for CA trust bundles in Issuer / ClusterIssuer resources via the `caBundleConfigMapName` specification. diff --git a/Dockerfile b/Dockerfile index cb1b130..d5f88f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum + + # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download diff --git a/Makefile b/Makefile index f30d196..7893a54 100644 --- a/Makefile +++ b/Makefile @@ -64,9 +64,10 @@ vet: ## Run go vet against code. test: manifests generate fmt vet envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out -# Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. -.PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. -test-e2e: +# Run e2e tests against the current kubeconfig context (set USE_MINIKUBE=true to use minikube instead) +# Configure e2e/.env with Command instance credentials before running +.PHONY: test-e2e +test-e2e: ## Run e2e tests against a Kubernetes cluster cd e2e && source .env && ./run_tests.sh .PHONY: lint diff --git a/cmd/main.go b/cmd/main.go index 1611f84..387b682 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -196,18 +196,23 @@ func main() { os.Exit(1) } - if defaultHealthCheckInterval < time.Duration(30) * time.Second { + if defaultHealthCheckInterval < time.Duration(30)*time.Second { setupLog.Error(errors.New(fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)), "invalid health check interval") os.Exit(1) } + // Create a shared client cache to avoid re-authenticating (fetching new OAuth tokens) + // for every certificate request. Clients are cached by configuration hash. + clientCache := command.NewClientCache() + setupLog.Info("initialized Command client cache for OAuth token reuse") + if err = (&controller.IssuerReconciler{ Client: mgr.GetClient(), Kind: "Issuer", ClusterResourceNamespace: clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, Scheme: mgr.GetScheme(), - HealthCheckerBuilder: command.NewHealthChecker, + HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker, DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Issuer") @@ -219,7 +224,7 @@ func main() { Kind: "ClusterIssuer", ClusterResourceNamespace: clusterResourceNamespace, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, - HealthCheckerBuilder: command.NewHealthChecker, + HealthCheckerBuilder: clientCache.GetOrCreateHealthChecker, DefaultHealthCheckInterval: defaultHealthCheckInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterIssuer") @@ -229,7 +234,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), ClusterResourceNamespace: clusterResourceNamespace, - SignerBuilder: command.NewSignerBuilder, + SignerBuilder: clientCache.GetOrCreateSigner, CheckApprovedCondition: !disableApprovedCheck, SecretAccessGrantedAtClusterLevel: secretAccessGrantedAtClusterLevel, Clock: clock.RealClock{}, diff --git a/e2e/.gitignore b/e2e/.gitignore index 0caca0b..85a5cd1 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -1,2 +1,3 @@ +.env certs/* !**/.gitkeep \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index 48b81b4..c0e52bd 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,19 +13,30 @@ The test suite does the following: This is currently configured as a Bash script, so it is necessary to run this on a UNIX-compatible machine. ## Requirements -- An available Command instance is running and configured as described in the [root README](../README.md#configuring-command) - - OAuth is used to communicate with Command + +**Local tools:** - Docker (>= 28.2.2) -- Minikube (>= v1.35.0) - kubectl (>= v1.32.2) - helm (>= v3.17.1) - cmctl (>= v2.1.1) +- Minikube (>= v1.35.0) - only required if using `USE_MINIKUBE=true` + +**Kubernetes cluster:** +- By default, tests run against your current kubeconfig context +- Set `USE_MINIKUBE=true` to use minikube instead + +**Command instance:** +- An available Command instance configured as described in the [root README](../README.md#configuring-command) +- OAuth credentials for API access +- An enrollment pattern (default: "Default Pattern") with CSR Enrollment enabled +- A security role (default: "InstanceOwner") with Enrollment permissions On the Command side: - An enrollment pattern is created called "Test Enrollment Pattern" that is has CSR Enrollment, CSR Generation, and PFX Enrollment enabled - A security role by the name of "InstanceOwner" exists and has the ability to perform Enrollment ## Configuring the environment variables + command-cert-manager-issuer interacts with an external Command instance. An environment variable file `.env` can be used to store the environment variables to be used to talk to the Command instance. A `.env.example` file is available as a template for your environment variables. @@ -35,24 +46,86 @@ A `.env.example` file is available as a template for your environment variables. cp .env.example .env ``` -Modify the fields as needed. +### Required variables + +| Variable | Description | +|----------|-------------| +| `HOSTNAME` | Command instance hostname | +| `API_PATH` | API path (default: `KeyfactorAPI`) | +| `OAUTH_TOKEN_URL` | OAuth token endpoint URL | +| `OAUTH_CLIENT_ID` | OAuth client ID | +| `OAUTH_CLIENT_SECRET` | OAuth client secret | +| `CERTIFICATE_TEMPLATE` | Certificate template short name | +| `CERTIFICATE_AUTHORITY_LOGICAL_NAME` | CA logical name in Command | + +### Optional variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `IMAGE_TAG` | Docker image version to test | `2.5.0` | +| `HELM_CHART_VERSION` | Helm chart version | `2.5.0` | +| `E2E_ENROLLMENT_PATTERN_NAME` | Enrollment pattern name | `Default Pattern` | +| `E2E_OWNER_ROLE_NAME` | Owner role name | `InstanceOwner` | +| `DISABLE_CA_CHECK` | Skip TLS CA verification | `false` | +| `USE_MINIKUBE` | Use minikube instead of current kubeconfig | `false` | +| `IMAGE_REGISTRY` | Registry to push local builds (when `IMAGE_TAG=local`) | - | ## Configuring the trusted certificate store + The issuer created in the end-to-end tests can leverage the `caSecretName` specification to determine a collection of CAs to trust in order to establish a trusted connection with the remote Keyfactor Command instance. The certificates defined in this secret will be pulled from the `certs` folder in this directory. -Please place the CA certificates for the Keyfactor Command instance you'd like to connect to (the intermediate and/or root CAs) under `certs` directory. +Place the CA certificates for the Keyfactor Command instance you'd like to connect to (the intermediate and/or root CAs) under `certs` directory. > NOTE: This check can be disabled by setting the env variable `DISABLE_CA_CHECK=true`. -## Running the script +## Running the tests + +### Using current kubeconfig context (default) + +```bash +# Configure your .env file first +source .env + +# Run the tests +./run_tests.sh +``` + +Or from the project root: +```bash +make test-e2e +``` + +### Using minikube ```bash -# enable the script to be executed -chmod +x ./run_tests.sh +export USE_MINIKUBE=true +source .env +./run_tests.sh +``` -# load the environment variables +### Testing a specific version + +```bash +export IMAGE_TAG="2.4.0" +export HELM_CHART_VERSION="2.4.0" source .env +./run_tests.sh +``` -# run the end-to-end tests +### Testing local changes + +```bash +# With minikube (image built directly into minikube's docker) +export IMAGE_TAG="local" +export HELM_CHART_VERSION="local" +export USE_MINIKUBE=true +source .env ./run_tests.sh -``` \ No newline at end of file + +# With a remote cluster (requires pushing to a registry) +export IMAGE_TAG="local" +export HELM_CHART_VERSION="local" +export IMAGE_REGISTRY="your-registry.com/your-repo" +source .env +./run_tests.sh +``` diff --git a/e2e/run_tests.sh b/e2e/run_tests.sh index 1d3ba30..fab2f06 100755 --- a/e2e/run_tests.sh +++ b/e2e/run_tests.sh @@ -36,15 +36,17 @@ ## =========================================================================== -IMAGE_REPO="keyfactor" -IMAGE_NAME="command-cert-manager-issuer" -# IMAGE_TAG="2.2.0-rc.9" # Uncomment if you want to use an existing image from the repository -IMAGE_TAG="local" # Uncomment if you want to build the image locally +# Image configuration - can be overridden via environment variables +# Set IMAGE_TAG=local to build locally, or use a published version (default: local) +IMAGE_REPO="${IMAGE_REPO:-keyfactor}" +IMAGE_NAME="${IMAGE_NAME:-command-cert-manager-issuer}" +IMAGE_TAG="${IMAGE_TAG:-local}" FULL_IMAGE_NAME="${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}" +# Helm chart configuration - can be overridden via environment variables +# Set HELM_CHART_VERSION=local to use the local chart, or use a published version (default: local) HELM_CHART_NAME="command-cert-manager-issuer" -# HELM_CHART_VERSION="2.1.0" # Uncomment if you want to use a specific version from the Helm repository -HELM_CHART_VERSION="local" # Uncomment if you want to use the local Helm chart +HELM_CHART_VERSION="${HELM_CHART_VERSION:-local}" IS_LOCAL_DEPLOYMENT=$([ "$IMAGE_TAG" = "local" ] && echo "true" || echo "false") IS_LOCAL_HELM=$([ "$HELM_CHART_VERSION" = "local" ] && echo "true" || echo "false") @@ -58,11 +60,11 @@ ISSUER_CR_NAME="issuer" ISSUER_CRD_FQTN="issuers.command-issuer.keyfactor.com" CLUSTER_ISSUER_CRD_FQTN="clusterissuers.command-issuer.keyfactor.com" -ENROLLMENT_PATTERN_ID=1 -ENROLLMENT_PATTERN_NAME="Test Enrollment Pattern" +ENROLLMENT_PATTERN_ID=${E2E_ENROLLMENT_PATTERN_ID:-1} +ENROLLMENT_PATTERN_NAME="${E2E_ENROLLMENT_PATTERN_NAME:-Default Pattern}" -OWNER_ROLE_ID=2 -OWNER_ROLE_NAME="InstanceOwner" +OWNER_ROLE_ID=${E2E_OWNER_ROLE_ID:-2} +OWNER_ROLE_NAME="${E2E_OWNER_ROLE_NAME:-InstanceOwner}" CHART_PATH="./deploy/charts/command-cert-manager-issuer" @@ -854,18 +856,20 @@ cd .. echo "⚙️ Local image deployment: ${IS_LOCAL_DEPLOYMENT}" echo "⚙️ Local Helm chart: ${IS_LOCAL_HELM}" -if ! minikube status &> /dev/null; then - echo "Error: Minikube is not running. Please start it with 'minikube start'" - exit 1 +# Use existing kubeconfig context (set USE_MINIKUBE=true to use minikube) +if [ "${USE_MINIKUBE:-false}" = "true" ]; then + if ! minikube status &> /dev/null; then + echo "Error: Minikube is not running. Please start it with 'minikube start'" + exit 1 + fi + kubectl config use-context minikube + echo "📡 Connecting to Minikube Docker environment..." + eval $(minikube docker-env) +else + echo "📡 Using current kubeconfig context..." fi - -kubectl config use-context minikube echo "Connected to Kubernetes context: $(kubectl config current-context)..." - -# 1. Connect to minikube Docker env -echo "📡 Connecting to Minikube Docker environment..." -eval $(minikube docker-env) -echo "🚀 Starting deployment to Minikube..." +echo "🚀 Starting deployment..." # 2. Deploy cert-manager Helm chart if not exists echo "🔐 Checking for cert-manager installation..." @@ -883,11 +887,25 @@ kubectl create namespace ${MANAGER_NAMESPACE} --dry-run=client -o yaml | kubectl # 4. Build the command-cert-manager-issuer Docker image # This step is only needed if the image tag is "local" -if "$IS_LOCAL_DEPLOYMENT" = "true"; then +if [ "$IS_LOCAL_DEPLOYMENT" = "true" ]; then + if [ "${USE_MINIKUBE:-false}" != "true" ]; then + echo "⚠️ WARNING: Local deployment without minikube requires pushing the image to a registry." + echo "⚠️ Set IMAGE_REGISTRY env var to push, or use a published IMAGE_TAG instead." + fi echo "🐳 Building ${FULL_IMAGE_NAME} Docker image..." docker build -t ${FULL_IMAGE_NAME} . echo "✅ Docker image built successfully" + # If IMAGE_REGISTRY is set, push the image + if [ -n "${IMAGE_REGISTRY:-}" ]; then + REMOTE_IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + echo "📤 Tagging and pushing image to ${REMOTE_IMAGE}..." + docker tag ${FULL_IMAGE_NAME} ${REMOTE_IMAGE} + docker push ${REMOTE_IMAGE} + FULL_IMAGE_NAME="${REMOTE_IMAGE}" + echo "✅ Image pushed successfully" + fi + echo "📦 Listing Docker images..." docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | head -5 fi diff --git a/go.mod b/go.mod index 5eec9a6..d2f1d31 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,9 @@ module github.com/Keyfactor/command-cert-manager-issuer -go 1.24 - -toolchain go1.24.0 +go 1.24.0 require ( - github.com/Keyfactor/keyfactor-auth-client-go v1.3.0 + github.com/Keyfactor/keyfactor-auth-client-go v1.3.1 github.com/Keyfactor/keyfactor-go-client-sdk/v25 v25.0.2 github.com/cert-manager/cert-manager v1.16.2 github.com/go-logr/logr v1.4.2 diff --git a/go.sum b/go.sum index d2d4df4..907e229 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/Keyfactor/keyfactor-auth-client-go v1.3.0 h1:otC213b6CYzqeN9b3CRlH1Qj1hTFIN5nqPA8gTlHdLg= -github.com/Keyfactor/keyfactor-auth-client-go v1.3.0/go.mod h1:97vCisBNkdCK0l2TuvOSdjlpvQa4+GHsMut1UTyv1jo= +github.com/Keyfactor/keyfactor-auth-client-go v1.3.1 h1:G45WsqH5CqMYSAcNHa7tm9fhvKHsc1BaFQS1X1eCfs4= +github.com/Keyfactor/keyfactor-auth-client-go v1.3.1/go.mod h1:97vCisBNkdCK0l2TuvOSdjlpvQa4+GHsMut1UTyv1jo= github.com/Keyfactor/keyfactor-go-client-sdk/v25 v25.0.2 h1:7VsZOYgMHAO2a1eeyVgDKel9TJXXYRQpd1EvSvp8lKA= github.com/Keyfactor/keyfactor-go-client-sdk/v25 v25.0.2/go.mod h1:VnVW8x+pChhnOWBR1PNYPeCQQjlWIK1bwHI8i8j7UPI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= diff --git a/internal/command/client_cache.go b/internal/command/client_cache.go new file mode 100644 index 0000000..d00a1f6 --- /dev/null +++ b/internal/command/client_cache.go @@ -0,0 +1,183 @@ +/* +Copyright © 2025 Keyfactor + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package command + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + + commandsdk "github.com/Keyfactor/keyfactor-go-client-sdk/v25" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ClientCache provides thread-safe caching of Command API clients to avoid +// re-authenticating (and fetching new OAuth tokens) for every request. +// Clients are cached by a hash of their configuration, so different issuers +// with different configs get different clients, but the same issuer reuses +// its client across reconciliations. +type ClientCache struct { + mu sync.RWMutex + clients map[string]*cachedClient +} + +type cachedClient struct { + signer *signer +} + +// NewClientCache creates a new ClientCache instance. +func NewClientCache() *ClientCache { + return &ClientCache{ + clients: make(map[string]*cachedClient), + } +} + +// configHash generates a unique hash for a Config to use as a cache key. +// This ensures that different configurations get different clients. +func configHash(config *Config) string { + h := sha256.New() + + // Include all fields that affect the client connection + h.Write([]byte(config.Hostname)) + h.Write([]byte(config.APIPath)) + h.Write(config.CaCertsBytes) + + if config.BasicAuth != nil { + h.Write([]byte("basic")) + h.Write([]byte(config.BasicAuth.Username)) + h.Write([]byte(config.BasicAuth.Password)) + } + + if config.OAuth != nil { + h.Write([]byte("oauth")) + h.Write([]byte(config.OAuth.TokenURL)) + h.Write([]byte(config.OAuth.ClientID)) + h.Write([]byte(config.OAuth.ClientSecret)) + h.Write([]byte(config.OAuth.Audience)) + for _, scope := range config.OAuth.Scopes { + h.Write([]byte(scope)) + } + } + + // Include ambient credential config + h.Write([]byte(config.AmbientCredentialAudience)) + for _, scope := range config.AmbientCredentialScopes { + h.Write([]byte(scope)) + } + + return hex.EncodeToString(h.Sum(nil)) +} + +// GetOrCreateSigner returns a cached signer for the given config, or creates +// a new one if none exists. This ensures OAuth tokens are reused across +// requests to the same Command instance. +func (c *ClientCache) GetOrCreateSigner(ctx context.Context, config *Config) (Signer, error) { + key := configHash(config) + logger := log.FromContext(ctx) + + // Fast path: check if we have a cached client + c.mu.RLock() + if cached, ok := c.clients[key]; ok { + c.mu.RUnlock() + logger.V(1).Info("Reusing cached Command client", "cacheKey", key[:12]) + return cached.signer, nil + } + c.mu.RUnlock() + + // Slow path: create a new client + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if cached, ok := c.clients[key]; ok { + logger.V(1).Info("Reusing cached Command client (after lock)", "cacheKey", key[:12]) + return cached.signer, nil + } + + logger.Info("Creating new Command client (will be cached for future requests)", "cacheKey", key[:12]) + + s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient) + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + + c.clients[key] = &cachedClient{signer: s} + return s, nil +} + +// GetOrCreateHealthChecker returns a cached health checker for the given config. +// Since the signer type implements both Signer and HealthChecker interfaces, +// this shares the same cache as GetOrCreateSigner. +func (c *ClientCache) GetOrCreateHealthChecker(ctx context.Context, config *Config) (HealthChecker, error) { + key := configHash(config) + logger := log.FromContext(ctx) + + // Fast path: check if we have a cached client + c.mu.RLock() + if cached, ok := c.clients[key]; ok { + c.mu.RUnlock() + logger.V(1).Info("Reusing cached Command client for health check", "cacheKey", key[:12]) + return cached.signer, nil + } + c.mu.RUnlock() + + // Slow path: create a new client + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if cached, ok := c.clients[key]; ok { + logger.V(1).Info("Reusing cached Command client for health check (after lock)", "cacheKey", key[:12]) + return cached.signer, nil + } + + logger.Info("Creating new Command client for health check (will be cached)", "cacheKey", key[:12]) + + s, err := newInternalSigner(ctx, config, commandsdk.NewAPIClient) + if err != nil { + return nil, fmt.Errorf("failed to create health checker: %w", err) + } + + c.clients[key] = &cachedClient{signer: s} + return s, nil +} + +// Invalidate removes a cached client for the given config. +// This should be called when an issuer's credentials are updated. +func (c *ClientCache) Invalidate(config *Config) { + key := configHash(config) + c.mu.Lock() + defer c.mu.Unlock() + delete(c.clients, key) +} + +// InvalidateAll removes all cached clients. +// This can be used during shutdown or when a global credential refresh is needed. +func (c *ClientCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + c.clients = make(map[string]*cachedClient) +} + +// Size returns the number of cached clients. +func (c *ClientCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.clients) +} diff --git a/internal/command/client_cache_test.go b/internal/command/client_cache_test.go new file mode 100644 index 0000000..162f7e3 --- /dev/null +++ b/internal/command/client_cache_test.go @@ -0,0 +1,198 @@ +/* +Copyright © 2025 Keyfactor + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package command + +import ( + "testing" +) + +func TestConfigHash(t *testing.T) { + tests := []struct { + name string + config1 *Config + config2 *Config + wantSame bool + }{ + { + name: "identical configs produce same hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + wantSame: true, + }, + { + name: "different hostnames produce different hash", + config1: &Config{ + Hostname: "test1.example.com", + APIPath: "KeyfactorAPI", + }, + config2: &Config{ + Hostname: "test2.example.com", + APIPath: "KeyfactorAPI", + }, + wantSame: false, + }, + { + name: "different OAuth credentials produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id-1", + ClientSecret: "client-secret", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id-2", + ClientSecret: "client-secret", + }, + }, + wantSame: false, + }, + { + name: "basic auth vs oauth produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + BasicAuth: &BasicAuth{ + Username: "user", + Password: "pass", + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + }, + }, + wantSame: false, + }, + { + name: "different scopes produce different hash", + config1: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1"}, + }, + }, + config2: &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1", "scope2"}, + }, + }, + wantSame: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash1 := configHash(tt.config1) + hash2 := configHash(tt.config2) + + if tt.wantSame && hash1 != hash2 { + t.Errorf("expected same hash, got different: %s vs %s", hash1, hash2) + } + if !tt.wantSame && hash1 == hash2 { + t.Errorf("expected different hash, got same: %s", hash1) + } + }) + } +} + +func TestClientCache_BasicOperations(t *testing.T) { + cache := NewClientCache() + + // Initial size should be 0 + if cache.Size() != 0 { + t.Errorf("expected empty cache, got size %d", cache.Size()) + } + + // After invalidating a non-existent config, size should still be 0 + cache.Invalidate(&Config{Hostname: "test.example.com"}) + if cache.Size() != 0 { + t.Errorf("expected empty cache after invalidating non-existent, got size %d", cache.Size()) + } + + // InvalidateAll on empty cache should work + cache.InvalidateAll() + if cache.Size() != 0 { + t.Errorf("expected empty cache after InvalidateAll, got size %d", cache.Size()) + } +} + +func TestConfigHash_Deterministic(t *testing.T) { + config := &Config{ + Hostname: "test.example.com", + APIPath: "KeyfactorAPI", + OAuth: &OAuth{ + TokenURL: "https://auth.example.com/token", + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"scope1", "scope2"}, + Audience: "audience", + }, + CaCertsBytes: []byte("ca-cert-data"), + AmbientCredentialAudience: "ambient-audience", + AmbientCredentialScopes: []string{"ambient-scope"}, + } + + // Hash should be deterministic + hash1 := configHash(config) + hash2 := configHash(config) + hash3 := configHash(config) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } + + // Hash should be a valid hex string of expected length (SHA-256 = 64 hex chars) + if len(hash1) != 64 { + t.Errorf("expected hash length 64, got %d", len(hash1)) + } +} From 63d15be8269ec0ddba2356fb3ac0d3be785fb1b3 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Thu, 30 Apr 2026 15:17:40 -0400 Subject: [PATCH 3/4] Add missing namespace specification + address linting issues (#66) * fix: log errors from Enrollment API call Signed-off-by: Matthew H. Irby * fix: add missing namespaces, add linting to catch issues Signed-off-by: Matthew H. Irby * feat: add linting Signed-off-by: Matthew H. Irby * chore: address lint issues Signed-off-by: Matthew H. Irby * chore: update CHANGELOG Signed-off-by: Matthew H. Irby * chore: apply copilot feedback Signed-off-by: Matthew H. Irby * feat: fix typo Signed-off-by: Matthew H. Irby * feat: copilot suggestions Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby --- .github/workflows/test.yml | 44 +- .golangci.yml | 58 ++- .kube-linter.yaml | 6 + CHANGELOG.md | 8 + CONTRIBUTING.md | 81 +++ Makefile | 42 +- api/v1alpha1/issuer_types.go | 5 +- api/v1alpha1/issuer_types_test.go | 2 +- cmd/main.go | 2 +- .../ci/cluster-access-values.yaml | 7 + .../ci/default-values.yaml | 7 + .../templates/clusterrole.yaml | 1 + .../templates/role.yaml | 7 + .../templates/rolebinding.yaml | 7 + .../templates/serviceaccount.yaml | 1 + internal/command/client.go | 30 +- internal/command/client_test.go | 16 +- internal/command/command.go | 24 +- internal/command/command_test.go | 127 ++--- .../certificaterequest_controller.go | 7 +- .../certificaterequest_controller_test.go | 43 -- internal/controller/issuer_controller.go | 8 +- internal/controller/issuer_controller_test.go | 477 +++++++++--------- policy/deployments.rego | 31 ++ policy/roles.rego | 58 +++ policy/serviceaccounts.rego | 13 + 26 files changed, 670 insertions(+), 442 deletions(-) create mode 100644 .kube-linter.yaml create mode 100644 CONTRIBUTING.md create mode 100644 deploy/charts/command-cert-manager-issuer/ci/cluster-access-values.yaml create mode 100644 deploy/charts/command-cert-manager-issuer/ci/default-values.yaml create mode 100644 policy/deployments.rego create mode 100644 policy/roles.rego create mode 100644 policy/serviceaccounts.rego diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0f0f80..f822e77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,6 @@ -name: Build and Test +name: Build Test and Lint on: + workflow_dispatch: pull_request: push: branches: @@ -7,9 +8,9 @@ on: - 'release-*.*' jobs: build: - name: Build and Lint + name: Build and Test runs-on: ubuntu-latest - timeout-minutes: 8 + timeout-minutes: 10 steps: # Checkout code # https://github.com/actions/checkout @@ -30,19 +31,13 @@ jobs: # Build Go binary - run: go build -v cmd/main.go - - name: Regenerate CRDs - run: make generate manifests - - - name: Check for CRD drift - run: | - git diff --compact-summary --exit-code || \ - (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) + - run: go test ./... - test: - name: Go Test + lint: + name: Lint needs: build runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 steps: # Checkout code # https://github.com/actions/checkout @@ -57,6 +52,23 @@ jobs: go-version-file: 'go.mod' cache: true - # Run Go tests - - name: Run go test - run: go test -v ./... \ No newline at end of file + - name: Install Helm + uses: azure/setup-helm@v3.5 + + # Run Go linters + # https://github.com/golangci/golangci-lint-action + - name: Run linters + uses: golangci/golangci-lint-action@v7 + with: + version: v2.4.0 + + - name: Regenerate CRDs + run: make generate manifests + + - name: Check for CRD drift + run: | + git diff --compact-summary --exit-code -- config/crd deploy/charts || \ + (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) + + - name: Lint Helm manifests + run: make lint-manifests \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 318dc6d..bd04587 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,32 +1,46 @@ -run: - # timeout for analysis, e.g. 30s, 5m, default is 1m - timeout: 12m - - skip-dirs: - - testdata$ - - test/mock - - go/pkg/mod - - skip-files: - - ".*\\.pb\\.go" - +version: "2" linters: enable: - bodyclose - durationcheck - errorlint - - goimports - - revive + - gocritic - gosec - misspell - nakedret + - nolintlint + - revive - unconvert - unparam - - whitespace - - gocritic - - nolintlint - -linters-settings: - revive: - # minimal confidence for issues, default is 0.8 - confidence: 0.0 + settings: + revive: + confidence: 0 + rules: + - name: var-naming + disabled: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - .*\.pb\.go + - testdata$ + - test/mock + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - .*\.pb\.go + - testdata$ + - test/mock + - third_party$ + - builtin$ + - examples$ \ No newline at end of file diff --git a/.kube-linter.yaml b/.kube-linter.yaml new file mode 100644 index 0000000..0b5b7ca --- /dev/null +++ b/.kube-linter.yaml @@ -0,0 +1,6 @@ +checks: + addAllBuiltIn: true + # Add project-specific exclusions below as needed, e.g.: + # exclude: + # - "unset-cpu-requirements" + # - "unset-memory-requirements" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 551abd1..71e444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 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. +- Fixes an issue where the error message from a failed Enrollment API call is not logged. +## Chores +- Update GitHub Actions workflow to check for policy enforcement on Helm chart rendered manifests in addition to checking for drift in generated CRDs. +- Fixes various linting issues in the codebase. + # v2.5.1 ## Fixes - Fixes an issue where OAuth 2.0 client credentials were being regenerated on every API call. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2669087 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Command Cert Manager Issuer Contribution Guide + +## Requirements +- Go (>= 1.24) +- golangci-lint (>= 2.4.0) ([installation notes](https://github.com/golangci/golangci-lint?tab=readme-ov-file#install-golangci-lint)) +- helm (>= 3.x) — required to render chart templates for manifest linting ([installation notes](https://helm.sh/docs/intro/install/)) +- conftest — policy testing tool powered by Open Policy Agent; installed automatically by `make lint-manifests` + +## Installing dependencies +Project dependencies can be installed by running the following: + +```bash +go mod download +``` + +The following command can be used to add missing requirements or remove unused modules: + +```bash +go mod tidy +``` + +## Running unit tests +The following command can be run to run the project unit tests: + +```bash +go test -v ./... +``` + +## Running linters +The project uses golangci-lint to lint the codebase. The following command can be run to run the linters: + +```bash +golangci-lint run +``` + +or, alternatively: + +```bash +make lint +``` + +## Updating generated manifests + +This command will update the generated custom resource definitions under `config/crd/bases`: + +```bash +make generate manifests +``` + +> [!IMPORTANT] +> There is no automated process to automatically update the CRDs under `deploy/charts/command-cert-manager-issuer`. If any changes are made to the CRDs, the generated CRDs under `config/crd/bases` must be copied to `deploy/charts/command-cert-manager-issuer/crds` to ensure the Helm chart is up to date. + +## Linting Helm manifests + +The Helm chart under `deploy/charts/command-cert-manager-issuer` is linted with two tools on every PR: +- **conftest** — runs custom Rego policies located in the [`policy/`](policy/) directory against the rendered manifests + +To run both checks locally: + +```bash +make lint-manifests +``` + +`conftest` is downloaded automatically into `bin/` on first use; no manual installation is required. + +To inspect the rendered templates without linting: + +```bash +make helm-template +``` + +### Adding or modifying policies + +Rego policies live in [`policy/`](policy/). Each `.rego` file in that directory is evaluated by conftest against every resource in the rendered chart. Add a new `.rego` file to enforce additional rules. For example, `policy/roles.rego` enforces that all `Role` resources declare an explicit namespace. + +kube-linter checks can be tuned in [.kube-linter.yaml](.kube-linter.yaml). To exclude a check, add its name under the `exclude` key. + +## Running end-to-end tests +A comprehensive end-to-end test suite is available to verify the issuer code works against cert-manager and a Keyfactor Command instance. + +Instructions on how to run the end-to-end test suite can be found [here](./e2e/README.md). \ No newline at end of file diff --git a/Makefile b/Makefile index 7893a54..5a3b40d 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,11 @@ endif # tools. (i.e. podman) CONTAINER_TOOL ?= docker +# Helm chart and Conftest policy directory for manifest linting +HELM_CHART_DIR ?= deploy/charts/command-cert-manager-issuer +HELM_RELEASE_NAME ?= command-cert-manager-issuer +POLICY_DIR ?= policy + # Setting SHELL to bash allows bash commands to be executed by recipes. # Options are set to exit when a recipe line exits non-zero or a piped command fails. SHELL = /usr/bin/env bash -o pipefail @@ -78,6 +83,20 @@ lint: golangci-lint ## Run golangci-lint linter & yamllint lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes $(GOLANGCI_LINT) run --fix +.PHONY: helm-template +helm-template: ## Render Helm chart templates to stdout (includes CRDs). + helm template $(HELM_RELEASE_NAME) $(HELM_CHART_DIR) --include-crds + +.PHONY: lint-manifests +lint-manifests: conftest ## Run Conftest policy checks against every CI values file in $(HELM_CHART_DIR)/ci/. + @failed=0; \ + for f in $(HELM_CHART_DIR)/ci/*-values.yaml; do \ + echo "==> $$(basename $$f)"; \ + helm template $(HELM_RELEASE_NAME) $(HELM_CHART_DIR) --include-crds -f "$$f" \ + | $(CONFTEST) test --policy $(POLICY_DIR) - || failed=1; \ + done; \ + exit $$failed + ##@ Build .PHONY: build @@ -162,18 +181,32 @@ KUSTOMIZE ?= $(LOCALBIN)/kustomize-$(KUSTOMIZE_VERSION) CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen-$(CONTROLLER_TOOLS_VERSION) ENVTEST ?= $(LOCALBIN)/setup-envtest-$(ENVTEST_VERSION) GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) +KUBE_LINTER = $(LOCALBIN)/kube-linter-$(KUBE_LINTER_VERSION) +CONFTEST = $(LOCALBIN)/conftest-$(CONFTEST_VERSION) ## Tool Versions KUSTOMIZE_VERSION ?= v5.3.0 CONTROLLER_TOOLS_VERSION ?= v0.14.0 ENVTEST_VERSION ?= latest -GOLANGCI_LINT_VERSION ?= v1.60.1 +GOLANGCI_LINT_VERSION ?= v2.4.0 +KUBE_LINTER_VERSION ?= v0.6.8 +CONFTEST_VERSION ?= v0.60.0 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. $(KUSTOMIZE): $(LOCALBIN) $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) +.PHONY: kube-linter +kube-linter: $(KUBE_LINTER) ## Download kube-linter locally if necessary. +$(KUBE_LINTER): $(LOCALBIN) + $(call go-install-tool,$(KUBE_LINTER),golang.stackrox.io/kube-linter/cmd/kube-linter,$(KUBE_LINTER_VERSION)) + +.PHONY: conftest +conftest: $(CONFTEST) ## Download conftest locally if necessary. +$(CONFTEST): $(LOCALBIN) + $(call go-install-tool,$(CONFTEST),github.com/open-policy-agent/conftest,$(CONFTEST_VERSION)) + .PHONY: controller-gen controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. $(CONTROLLER_GEN): $(LOCALBIN) @@ -187,7 +220,12 @@ $(ENVTEST): $(LOCALBIN) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) + @[ -f $(GOLANGCI_LINT) ] || { \ + set -e; \ + echo "Downloading golangci-lint $(GOLANGCI_LINT_VERSION)" ;\ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh | sh -s -- -b $(LOCALBIN) $(GOLANGCI_LINT_VERSION) ;\ + mv $(LOCALBIN)/golangci-lint $(GOLANGCI_LINT) ;\ + } # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary (ideally with version) diff --git a/api/v1alpha1/issuer_types.go b/api/v1alpha1/issuer_types.go index 2b5b669..deba1d6 100644 --- a/api/v1alpha1/issuer_types.go +++ b/api/v1alpha1/issuer_types.go @@ -201,10 +201,9 @@ func (is *IssuerStatus) HasCondition(conditionType IssuerConditionType, state Co } func (is *IssuerStatus) UnsetCondition(conditionType IssuerConditionType) { - conditions := is.Conditions - for i, c := range conditions { + for i, c := range is.Conditions { if c.Type == conditionType { - is.Conditions = append(conditions[:i], conditions[i+1:]...) + is.Conditions = append(is.Conditions[:i], is.Conditions[i+1:]...) return } } diff --git a/api/v1alpha1/issuer_types_test.go b/api/v1alpha1/issuer_types_test.go index 261df05..0628398 100644 --- a/api/v1alpha1/issuer_types_test.go +++ b/api/v1alpha1/issuer_types_test.go @@ -68,7 +68,7 @@ func TestIssuerStatus_SetCondition_UpdateConditionStatus(t *testing.T) { assert.Equal(t, "NewMessage", cond.Message) // LastTransitionTime should be updated because status changed from ConditionFalse -> ConditionTrue - assert.True(t, cond.LastTransitionTime.Time.After(now.Time), "LastTransitionTime should be more recent if the status changed.") + assert.True(t, cond.LastTransitionTime.After(now.Time), "LastTransitionTime should be more recent if the status changed.") } func TestIssuerStatus_SetCondition_NoStatusChange(t *testing.T) { diff --git a/cmd/main.go b/cmd/main.go index 387b682..124a8f4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -197,7 +197,7 @@ func main() { } if defaultHealthCheckInterval < time.Duration(30)*time.Second { - setupLog.Error(errors.New(fmt.Sprintf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval)), "invalid health check interval") + setupLog.Error(fmt.Errorf("interval %s is invalid, must be greater than or equal to '30s'", healthCheckInterval), "invalid health check interval") os.Exit(1) } diff --git a/deploy/charts/command-cert-manager-issuer/ci/cluster-access-values.yaml b/deploy/charts/command-cert-manager-issuer/ci/cluster-access-values.yaml new file mode 100644 index 0000000..9cb9785 --- /dev/null +++ b/deploy/charts/command-cert-manager-issuer/ci/cluster-access-values.yaml @@ -0,0 +1,7 @@ +# Cluster-wide access configuration — exercises ClusterRole/ClusterRoleBinding +# paths for secret and configmap access, and enables secure metrics. +secretConfig: + useClusterRoleForSecretAccess: true + useClusterRoleForConfigMapAccess: true +metrics: + secure: true \ No newline at end of file diff --git a/deploy/charts/command-cert-manager-issuer/ci/default-values.yaml b/deploy/charts/command-cert-manager-issuer/ci/default-values.yaml new file mode 100644 index 0000000..16d98a7 --- /dev/null +++ b/deploy/charts/command-cert-manager-issuer/ci/default-values.yaml @@ -0,0 +1,7 @@ +# Default configuration — all flags at their chart defaults. +# Role/RoleBinding namespace-scoped paths are exercised. +secretConfig: + useClusterRoleForSecretAccess: false + useClusterRoleForConfigMapAccess: false +metrics: + secure: false \ No newline at end of file diff --git a/deploy/charts/command-cert-manager-issuer/templates/clusterrole.yaml b/deploy/charts/command-cert-manager-issuer/templates/clusterrole.yaml index 13fd364..f786533 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/clusterrole.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/clusterrole.yaml @@ -42,6 +42,7 @@ rules: - apiGroups: - command-issuer.keyfactor.com resources: + - clusterissuers/finalizers - issuers/finalizers verbs: - update diff --git a/deploy/charts/command-cert-manager-issuer/templates/role.yaml b/deploy/charts/command-cert-manager-issuer/templates/role.yaml index eff6781..00acf3d 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/role.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/role.yaml @@ -4,6 +4,7 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-leader-election-role + namespace: {{ .Release.Namespace }} rules: - apiGroups: - coordination.k8s.io @@ -31,6 +32,9 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-secret-reader-role + {{- if not .Values.secretConfig.useClusterRoleForSecretAccess }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - "" @@ -47,6 +51,9 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-role + {{- if not .Values.secretConfig.useClusterRoleForConfigMapAccess }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - "" diff --git a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml index 1125fd9..9ed93ec 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/rolebinding.yaml @@ -4,6 +4,7 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-leader-election-rolebinding + namespace: {{ .Release.Namespace }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role @@ -19,6 +20,9 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-secret-reader-rolebinding + {{- if not .Values.secretConfig.useClusterRoleForSecretAccess }} + namespace: {{ .Release.Namespace }} + {{- end }} roleRef: apiGroup: rbac.authorization.k8s.io kind: {{ if .Values.secretConfig.useClusterRoleForSecretAccess }}ClusterRole{{ else }}Role{{ end }} @@ -34,6 +38,9 @@ metadata: labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} name: {{ include "command-cert-manager-issuer.name" . }}-configmap-reader-rolebinding + {{- if not .Values.secretConfig.useClusterRoleForConfigMapAccess }} + namespace: {{ .Release.Namespace }} + {{- end }} roleRef: apiGroup: rbac.authorization.k8s.io kind: {{ if .Values.secretConfig.useClusterRoleForConfigMapAccess }}ClusterRole{{ else }}Role{{ end }} diff --git a/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml b/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml index ccefb2e..948bf2a 100644 --- a/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml +++ b/deploy/charts/command-cert-manager-issuer/templates/serviceaccount.yaml @@ -3,6 +3,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "command-cert-manager-issuer.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} labels: {{- include "command-cert-manager-issuer.labels" . | nindent 4 }} {{- if .Values.serviceAccount.labels }} diff --git a/internal/command/client.go b/internal/command/client.go index 36616e3..416ebb7 100644 --- a/internal/command/client.go +++ b/internal/command/client.go @@ -18,6 +18,7 @@ package command import ( "fmt" + "io" "net/http" "strings" "time" @@ -49,7 +50,7 @@ func setAmbientTokenCredentialSource(source TokenCredentialSource) { type Client interface { EnrollCSR(v1.ApiCreateEnrollmentCSRRequest) (*v1.CSSCMSDataModelModelsEnrollmentCSREnrollmentResponse, *http.Response, error) - GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, *http.Response, error) + GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, error) GetEnrollmentPatterns(v1.ApiGetEnrollmentPatternsRequest) ([]v1.EnrollmentPatternsEnrollmentPatternResponse, *http.Response, error) TestConnection() error } @@ -65,16 +66,21 @@ type clientAdapter struct { testConnection func() error } +// GetAllMetadataFields implements Client. Closes the response body internally so callers don't need to. +func (c *clientAdapter) GetAllMetadataFields(r v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, error) { + fields, resp, err := c.getAllMetadataFields(r) + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + return fields, err +} + // EnrollCSR implements CertificateClient. func (c *clientAdapter) EnrollCSR(r v1.ApiCreateEnrollmentCSRRequest) (*v1.CSSCMSDataModelModelsEnrollmentCSREnrollmentResponse, *http.Response, error) { return c.enrollCSR(r) } -// GetAllMetadataFields implements Client. -func (c *clientAdapter) GetAllMetadataFields(r v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, *http.Response, error) { - return c.getAllMetadataFields(r) -} - func (c *clientAdapter) GetEnrollmentPatterns(r v1.ApiGetEnrollmentPatternsRequest) ([]v1.EnrollmentPatternsEnrollmentPatternResponse, *http.Response, error) { return c.getEnrollmentPatterns(r) } @@ -189,7 +195,7 @@ func (g *gcp) GetAccessToken(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("%w: failed to find GCP ADC: %w", errTokenFetchFailure, err) } - log.Info(fmt.Sprintf("generating a Google OIDC ID token...")) + log.Info("generating a Google OIDC ID token...") // Default audience to "command" if not provided aud := getValueOrDefault(g.audience, "command") @@ -220,7 +226,9 @@ func (g *gcp) GetAccessToken(ctx context.Context) (string, error) { // Only want to output this once, don't want to output this every time the JWT is generated log.Info("==== BEGIN DEBUG: Default Google ID Token JWT ======") + printClaims(log, token.AccessToken, []string{"aud", "iss", "sub", "email"}) + log.Info("==== END DEBUG: Default Google ID Token JWT ======") } @@ -242,11 +250,11 @@ func newGCPDefaultCredentialSource(ctx context.Context, audience string, scopes return source, nil } -func printClaims(log logr.Logger, token string, claimsToPrint []string) error { +func printClaims(log logr.Logger, token string, claimsToPrint []string) { tokenRaw, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) if err != nil { log.Error(err, "failed to parse JWT") - return fmt.Errorf("failed to parse JWT: %w", err) + return } claims, _ := tokenRaw.Claims.(jwt.MapClaims) @@ -258,9 +266,7 @@ func printClaims(log logr.Logger, token string, claimsToPrint []string) error { } } - if issuer, err := claims.GetIssuer(); err != nil { + if issuer, err := claims.GetIssuer(); err == nil && issuer != "" { log.Info(fmt.Sprintf("\nNOTE: If you are receiving a HTTP 401 on requests to Command, make sure an identity provider in Command is configured with '%s' as the authority.\nThe discovery endpoint for your issuer can be found at %s/.well-known/openid-configuration.", issuer, issuer)) } - - return nil } diff --git a/internal/command/client_test.go b/internal/command/client_test.go index 80b3b78..845064d 100644 --- a/internal/command/client_test.go +++ b/internal/command/client_test.go @@ -21,7 +21,6 @@ import ( "github.com/go-logr/logr/testr" "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/assert" ) func TestPrintClaims(t *testing.T) { @@ -36,8 +35,7 @@ func TestPrintClaims(t *testing.T) { token := createUnsignedJWT(t, claims) // Call the function - err := printClaims(testLogger, token, []string{"aud", "iss", "sub"}) - assert.NoError(t, err) + printClaims(testLogger, token, []string{"aud", "iss", "sub"}) }) t.Run("jwt with no issuer does not error", func(t *testing.T) { @@ -49,8 +47,7 @@ func TestPrintClaims(t *testing.T) { token := createUnsignedJWT(t, claims) // Call the function - err := printClaims(testLogger, token, []string{"aud", "iss", "sub"}) - assert.NoError(t, err) + printClaims(testLogger, token, []string{"aud", "iss", "sub"}) }) t.Run("jwt with empty claims does not error", func(t *testing.T) { @@ -59,20 +56,17 @@ func TestPrintClaims(t *testing.T) { token := createUnsignedJWT(t, claims) // Call the function - err := printClaims(testLogger, token, []string{"aud", "iss", "sub"}) - assert.NoError(t, err) + printClaims(testLogger, token, []string{"aud", "iss", "sub"}) }) t.Run("invalid jwt returns an error", func(t *testing.T) { // Call the function - err := printClaims(testLogger, "abcdefghijklmnop", []string{"aud", "iss", "sub"}) - assert.Error(t, err) + printClaims(testLogger, "abcdefghijklmnop", []string{"aud", "iss", "sub"}) }) t.Run("jwt with empty payload returns error", func(t *testing.T) { // Call the function - err := printClaims(testLogger, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0..", []string{"aud", "iss", "sub"}) - assert.Error(t, err) + printClaims(testLogger, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0..", []string{"aud", "iss", "sub"}) }) } diff --git a/internal/command/command.go b/internal/command/command.go index 025175f..a88a3d5 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -320,7 +320,7 @@ func (s *signer) Check(ctx context.Context) error { // CommandSupportsMetadata implements HealthChecker. func (s *signer) CommandSupportsMetadata() (bool, error) { - existingFields, _, err := s.client.GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest{}) + existingFields, err := s.client.GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest{}) if err != nil { return false, fmt.Errorf("failed to fetch metadata fields from connected Command instance: %w", err) } @@ -373,7 +373,7 @@ func (s *signer) Sign(ctx context.Context, csrBytes []byte, config *SignConfig) return nil, nil, err } - request, model, caBuilder, err := s.buildCsrEnrollRequest(ctx, config, k8sLog, csrBytes) + request, model, caBuilder, err := s.buildCsrEnrollRequest(config, k8sLog, csrBytes) if err != nil { return nil, nil, err } @@ -409,7 +409,7 @@ func (s *signer) Sign(ctx context.Context, csrBytes []byte, config *SignConfig) } if certificateOwnerId != nil || certificateOwnerName != nil { - detail += fmt.Sprintf(". Make sure the cert manager issuer's security context is assigned to the owner role name or ID.") + detail += ". Make sure the cert manager issuer's security context is assigned to the owner role name or ID." } if len(extractMetadataFromAnnotations(config.Annotations)) > 0 { @@ -421,8 +421,10 @@ func (s *signer) Sign(ctx context.Context, csrBytes []byte, config *SignConfig) defer httpResp.Body.Close() bodyBytes, err := io.ReadAll(httpResp.Body) - if err != nil { - detail += fmt.Sprintf(". Error response: %s", string(bodyBytes)) + 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") } } } @@ -468,7 +470,7 @@ func extractMetadataFromAnnotations(annotations map[string]string) map[string]in } // Builds the CSR Enrollment request we will send to the /Enroll/CSR endpoint -func (s *signer) buildCsrEnrollRequest(ctx context.Context, config *SignConfig, k8sLog logr.Logger, csrBytes []byte) (*v1.ApiCreateEnrollmentCSRRequest, *v1.EnrollmentCSREnrollmentRequest, *strings.Builder, error) { +func (s *signer) buildCsrEnrollRequest(config *SignConfig, k8sLog logr.Logger, csrBytes []byte) (*v1.ApiCreateEnrollmentCSRRequest, *v1.EnrollmentCSREnrollmentRequest, *strings.Builder, error) { // Override defaults from annotations if err := updateConfigWithOverrides(config, k8sLog); err != nil { return nil, nil, nil, err @@ -517,7 +519,7 @@ func (s *signer) buildCsrEnrollRequest(ctx context.Context, config *SignConfig, k8sLog.Info(fmt.Sprintf("Using enrollment pattern ID from config. ID: %d", config.EnrollmentPatternId)) enrollmentPatternId = &config.EnrollmentPatternId } else if config.EnrollmentPatternName != "" { - pattern, err := getEnrollmentPatternByName(ctx, k8sLog, s, config.EnrollmentPatternName) + pattern, err := getEnrollmentPatternByName(k8sLog, s, config.EnrollmentPatternName) if err != nil { return nil, nil, nil, err } @@ -613,7 +615,7 @@ func getMetadataOverrideOrCurrentValue[T any](currentValue T, annotations map[st var conv int64 conv, err = strconv.ParseInt(value, 10, 32) if err != nil { - return zero, fmt.Errorf("%w: failed to parse %s from annotations: %s", errInvalidSignerConfig, key, err) + return zero, fmt.Errorf("%w: failed to parse %s from annotations: %w", errInvalidSignerConfig, key, err) } result = int32(conv) case string: @@ -649,19 +651,21 @@ func ptr[T any](v T) *T { } // getEnrollmentPatternByName retrieves an enrollment pattern by its name from Command. -func getEnrollmentPatternByName(ctx context.Context, log logr.Logger, s *signer, enrollmentPatternName string) (*v1.EnrollmentPatternsEnrollmentPatternResponse, error) { +func getEnrollmentPatternByName(log logr.Logger, s *signer, enrollmentPatternName string) (*v1.EnrollmentPatternsEnrollmentPatternResponse, error) { log.Info(fmt.Sprintf("Looking up enrollment pattern %q in Command...", enrollmentPatternName)) var model *v1.EnrollmentPatternsEnrollmentPatternResponse queryString := fmt.Sprintf("Name -eq \"%s\"", enrollmentPatternName) patterns, httpResp, err := s.client.GetEnrollmentPatterns(v1.ApiGetEnrollmentPatternsRequest{}.QueryString(queryString)) + if httpResp != nil && httpResp.Body != nil { + defer httpResp.Body.Close() + } if err != nil { // Capture the error message which should indicate the failure reason msg := "" if httpResp != nil && httpResp.Body != nil { - defer httpResp.Body.Close() bodyBytes, _ := io.ReadAll(httpResp.Body) msg += string(bodyBytes) } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index bd46235..ac0faf5 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -17,6 +17,7 @@ limitations under the License. package command import ( + "bytes" "context" "crypto/ecdsa" "crypto/elliptic" @@ -27,6 +28,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "math/big" "net" "net/http" @@ -260,26 +262,6 @@ func TestSignConfigValidate(t *testing.T) { } } -type fakeCommandAuthenticator struct { - client *http.Client - config *auth_providers.Server -} - -// Authenticate implements api.AuthConfig. -func (f *fakeCommandAuthenticator) Authenticate() error { - return nil -} - -// GetHttpClient implements api.AuthConfig. -func (f *fakeCommandAuthenticator) GetHttpClient() (*http.Client, error) { - return f.client, nil -} - -// GetServerConfig implements api.AuthConfig. -func (f *fakeCommandAuthenticator) GetServerConfig() *auth_providers.Server { - return f.config -} - func TestNewServerConfig(t *testing.T) { testCases := map[string]struct { @@ -366,8 +348,9 @@ var ( ) type fakeClient struct { - enrollCallback func(v1.ApiCreateEnrollmentCSRRequest) - enrollResponse *v1.CSSCMSDataModelModelsEnrollmentCSREnrollmentResponse + enrollCallback func(v1.ApiCreateEnrollmentCSRRequest) + enrollResponse *v1.CSSCMSDataModelModelsEnrollmentCSREnrollmentResponse + enrollHTTPResponse *http.Response metadataFields []v1.CSSCMSDataModelModelsMetadataType enrollmentPatterns []v1.EnrollmentPatternsEnrollmentPatternResponse @@ -380,12 +363,12 @@ func (f *fakeClient) EnrollCSR(r v1.ApiCreateEnrollmentCSRRequest) (*v1.CSSCMSDa if f.enrollCallback != nil { f.enrollCallback(r) } - return f.enrollResponse, nil, f.err + return f.enrollResponse, f.enrollHTTPResponse, f.err } // GetAllMetadataFields implements Client. -func (f *fakeClient) GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, *http.Response, error) { - return f.metadataFields, nil, f.err +func (f *fakeClient) GetAllMetadataFields(v1.ApiGetMetadataFieldsRequest) ([]v1.CSSCMSDataModelModelsMetadataType, error) { + return f.metadataFields, f.err } // GetEnrollmentPatterns implements Client. @@ -427,13 +410,15 @@ func TestSign(t *testing.T) { testCases := map[string]struct { enrollCSRFunctionError error + enrollHTTPResponse *http.Response enrollmentPatterns []v1.EnrollmentPatternsEnrollmentPatternResponse // Request config *SignConfig // Expected - expectedSignError error + expectedSignError error + expectedErrorContent *string }{ "success-no-meta-certificate-template": { // Request @@ -444,8 +429,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-no-meta-enrollment-pattern-id": { // Request @@ -456,8 +439,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-no-meta-enrollment-pattern-name": { enrollmentPatterns: []v1.EnrollmentPatternsEnrollmentPatternResponse{ @@ -475,8 +456,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-no-meta-enrollment-pattern-id-overwrites-pattern-name": { enrollmentPatterns: []v1.EnrollmentPatternsEnrollmentPatternResponse{}, // This would fail if enrollment pattern name was used @@ -489,8 +468,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-annotation-config-override-pattern-id": { // Request @@ -507,8 +484,6 @@ func TestSign(t *testing.T) { "command-issuer.keyfactor.com/enrollmentPatternId": "12345", }, }, - - expectedSignError: nil, }, "success-annotation-config-override-pattern-name": { enrollmentPatterns: []v1.EnrollmentPatternsEnrollmentPatternResponse{ @@ -532,8 +507,6 @@ func TestSign(t *testing.T) { "command-issuer.keyfactor.com/enrollmentPatternName": "enrollment-pattern-override", }, }, - - expectedSignError: nil, }, "success-no-meta-owner-role-id": { // Request @@ -545,8 +518,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-no-meta-owner-role-name": { // Request @@ -558,8 +529,6 @@ func TestSign(t *testing.T) { Meta: nil, Annotations: nil, }, - - expectedSignError: nil, }, "success-predefined-meta": { // Request @@ -579,8 +548,6 @@ func TestSign(t *testing.T) { }, Annotations: nil, }, - - expectedSignError: nil, }, "success-custom-meta": { // Request @@ -593,8 +560,6 @@ func TestSign(t *testing.T) { fmt.Sprintf("%s%s", commandMetadataAnnotationPrefix, "testMetadata"): "test", }, }, - - expectedSignError: nil, }, "enroll-csr-err": { enrollCSRFunctionError: errors.New("an error from Command"), @@ -607,7 +572,27 @@ func TestSign(t *testing.T) { Annotations: nil, }, - expectedSignError: errCommandEnrollmentFailure, + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr("an error from Command"), + }, + "enroll-csr-err-response-body-included-in-error": { + enrollCSRFunctionError: errors.New("an error from Command"), + enrollHTTPResponse: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Message":"certificate template not found"}`))), + }, + // Request + config: &SignConfig{ + CertificateTemplate: certificateTemplateName, + CertificateAuthorityLogicalName: certificateAuthorityLogicalName, + CertificateAuthorityHostname: certificateAuthorityHostname, + Meta: nil, + Annotations: nil, + }, + + expectedSignError: errCommandEnrollmentFailure, + expectedErrorContent: ptr("certificate template not found"), }, "enroll-csr-err-enrollment-pattern-not-found": { enrollmentPatterns: []v1.EnrollmentPatternsEnrollmentPatternResponse{}, @@ -635,6 +620,7 @@ func TestSign(t *testing.T) { err: tc.enrollCSRFunctionError, enrollResponse: certificateRestResponseFromExpectedCerts(t, expectedLeafAndChain, []*x509.Certificate{caCert}), + enrollHTTPResponse: tc.enrollHTTPResponse, enrollmentPatterns: tc.enrollmentPatterns, enrollCallback: cb, } @@ -642,13 +628,17 @@ func TestSign(t *testing.T) { client: &client, } - csrBytes, err := generateCSR("CN=command.example.org", nil, nil, nil) + 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}) leafAndCA, root, err := signer.Sign(context.Background(), csrPem, tc.config) if tc.expectedSignError != nil { assertErrorIs(t, tc.expectedSignError, err) + + if tc.expectedErrorContent != nil { + assert.Contains(t, err.Error(), *tc.expectedErrorContent, "error message should contain content from response body when enrollCSR returns an error") + } } else { assert.NoError(t, err) @@ -849,7 +839,6 @@ func TestGetMetadataOverrideOrCurrentValue(t *testing.T) { } func TestBuildCsrEnrollRequest_Success(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -858,7 +847,7 @@ func TestBuildCsrEnrollRequest_Success(t *testing.T) { } // Create test CSR - csrBytes, err := generateCSR("CN=command.example.org", nil, nil, nil) + csrBytes, err := generateCSR("CN=command1.example.org", nil, nil, nil) require.NoError(t, err) csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes.Raw}) @@ -884,7 +873,7 @@ func TestBuildCsrEnrollRequest_Success(t *testing.T) { }, } - req, result, caBuilder, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + req, result, caBuilder, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) @@ -937,7 +926,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateTemplate(t *testing.T) { }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -952,7 +940,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateTemplate(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.Template.Get(), *result.Template.Get()) @@ -987,7 +975,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateAuthorityLogicalName(t * }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1002,7 +989,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateAuthorityLogicalName(t * for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.CertificateAuthority.Get(), *result.CertificateAuthority.Get()) @@ -1037,7 +1024,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateAuthorityHostname(t *tes }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1052,7 +1038,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_CertificateAuthorityHostname(t *tes for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.CertificateAuthority.Get(), *result.CertificateAuthority.Get()) @@ -1087,7 +1073,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_EnrollmentPatternId(t *testing.T) { }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1102,7 +1087,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_EnrollmentPatternId(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.EnrollmentPatternId.Get(), *result.EnrollmentPatternId.Get()) @@ -1137,7 +1122,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_OwnerRoleId(t *testing.T) { }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1152,7 +1136,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_OwnerRoleId(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.OwnerRoleId.Get(), *result.OwnerRoleId.Get()) @@ -1187,7 +1171,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_OwnerRoleName(t *testing.T) { }, } - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1202,7 +1185,7 @@ func TestBuildCsrEnrollRequest_WithOverrides_OwnerRoleName(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - _, result, _, err := signer.buildCsrEnrollRequest(ctx, &tc.config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(&tc.config, logger, csrPem) assert.NoError(t, err) assert.Equal(t, *tc.expected.OwnerRoleName.Get(), *result.OwnerRoleName.Get()) @@ -1211,7 +1194,6 @@ func TestBuildCsrEnrollRequest_WithOverrides_OwnerRoleName(t *testing.T) { } func TestBuildCsrEnrollRequest_NoCertificateTemplate_TemplatePropertyIsNil(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1228,7 +1210,7 @@ func TestBuildCsrEnrollRequest_NoCertificateTemplate_TemplatePropertyIsNil(t *te CertificateTemplate: "", } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) @@ -1236,7 +1218,6 @@ func TestBuildCsrEnrollRequest_NoCertificateTemplate_TemplatePropertyIsNil(t *te } func TestBuildCsrEnrollRequest_EnrollmentPatternNameAndEnrollmentPatternIdPopulated_UsesEnrollmentPatternIdValue(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1254,7 +1235,7 @@ func TestBuildCsrEnrollRequest_EnrollmentPatternNameAndEnrollmentPatternIdPopula EnrollmentPatternName: "TestEnrollmentPatternId", } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) @@ -1262,7 +1243,6 @@ func TestBuildCsrEnrollRequest_EnrollmentPatternNameAndEnrollmentPatternIdPopula } func TestBuildCsrEnrollRequest_EnrollmentPatternNameIsPopulated_GetsEnrollmentPatternFromClient(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{ @@ -1285,7 +1265,7 @@ func TestBuildCsrEnrollRequest_EnrollmentPatternNameIsPopulated_GetsEnrollmentPa EnrollmentPatternName: "TestEnrollmentPatternId", } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) @@ -1293,7 +1273,6 @@ func TestBuildCsrEnrollRequest_EnrollmentPatternNameIsPopulated_GetsEnrollmentPa } func TestBuildCsrEnrollRequest_OwnerRoleIdIsPopulated_UsesOwnerRoleIdValue(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1310,7 +1289,7 @@ func TestBuildCsrEnrollRequest_OwnerRoleIdIsPopulated_UsesOwnerRoleIdValue(t *te OwnerRoleId: 123, } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) @@ -1318,7 +1297,6 @@ func TestBuildCsrEnrollRequest_OwnerRoleIdIsPopulated_UsesOwnerRoleIdValue(t *te } func TestBuildCsrEnrollRequest_OwnerRoleNameIsPopulated_UsesOwnerRoleNameValue(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1335,7 +1313,7 @@ func TestBuildCsrEnrollRequest_OwnerRoleNameIsPopulated_UsesOwnerRoleNameValue(t OwnerRoleName: "TestOwnerRole", } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) @@ -1343,7 +1321,6 @@ func TestBuildCsrEnrollRequest_OwnerRoleNameIsPopulated_UsesOwnerRoleNameValue(t } func TestBuildCsrEnrollRequest_OwnerRoleIdAndNameArePopulated_UsesOwnerRoleIdValue(t *testing.T) { - ctx := context.Background() logger := logr.FromContextOrDiscard(context.Background()) client := fakeClient{} @@ -1361,7 +1338,7 @@ func TestBuildCsrEnrollRequest_OwnerRoleIdAndNameArePopulated_UsesOwnerRoleIdVal OwnerRoleName: "TestOwnerRole", } - _, result, _, err := signer.buildCsrEnrollRequest(ctx, config, logger, csrPem) + _, result, _, err := signer.buildCsrEnrollRequest(config, logger, csrPem) assert.NoError(t, err) assert.NotNil(t, result) diff --git a/internal/controller/certificaterequest_controller.go b/internal/controller/certificaterequest_controller.go index 66d6ec7..8df7e24 100644 --- a/internal/controller/certificaterequest_controller.go +++ b/internal/controller/certificaterequest_controller.go @@ -69,7 +69,7 @@ func (r *CertificateRequestReconciler) Reconcile(ctx context.Context, req ctrl.R var certificateRequest cmapi.CertificateRequest if err := r.Get(ctx, req.NamespacedName, &certificateRequest); err != nil { if err := client.IgnoreNotFound(err); err != nil { - return ctrl.Result{}, fmt.Errorf("unexpected get error: %v", err) + return ctrl.Result{}, fmt.Errorf("unexpected get error: %w", err) } log.Info("CertificateRequest not found. ignoring.") return ctrl.Result{}, nil @@ -159,7 +159,7 @@ func (r *CertificateRequestReconciler) Reconcile(ctx context.Context, req ctrl.R issuerGVK := commandissuer.GroupVersion.WithKind(certificateRequest.Spec.IssuerRef.Kind) issuerRO, err := r.Scheme.New(issuerGVK) if err != nil { - err = fmt.Errorf("%w: %v", errIssuerRef, err) + err = fmt.Errorf("%w: %w", errIssuerRef, err) log.Error(err, "Unrecognized kind. Ignoring.") setCertificateRequestReadyCondition(&certificateRequest, cmmeta.ConditionFalse, cmapi.CertificateRequestReasonFailed, err.Error()) return ctrl.Result{}, nil @@ -215,7 +215,7 @@ func (r *CertificateRequestReconciler) Reconcile(ctx context.Context, req ctrl.R commandSigner, err := r.SignerBuilder(ctx, config) if err != nil { - return ctrl.Result{}, fmt.Errorf("%w: %v", errSignerBuilder, err) + return ctrl.Result{}, fmt.Errorf("%w: %w", errSignerBuilder, err) } // Assign metadata @@ -254,6 +254,7 @@ func (r *CertificateRequestReconciler) Reconcile(ctx context.Context, req ctrl.R certificateRequest.Status.CA = chain setCertificateRequestReadyCondition(&certificateRequest, cmmeta.ConditionTrue, cmapi.CertificateRequestReasonIssued, "Signed") + log.Info(fmt.Sprintf("CertificateRequest %s/%s successfully signed", certificateRequest.Namespace, certificateRequest.Name)) return ctrl.Result{}, nil } diff --git a/internal/controller/certificaterequest_controller_test.go b/internal/controller/certificaterequest_controller_test.go index e01d633..30af28b 100644 --- a/internal/controller/certificaterequest_controller_test.go +++ b/internal/controller/certificaterequest_controller_test.go @@ -18,15 +18,8 @@ package controller import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" "errors" - "math/big" "testing" - "time" commandissuerv1alpha1 "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" @@ -806,39 +799,3 @@ func assertCertificateRequestHasReadyCondition(t *testing.T, status cmmeta.Condi assert.Contains(t, validReasons, reason, "unexpected condition reason") assert.Equal(t, reason, condition.Reason, "unexpected condition reason") } - -func issueTestCertificate(t *testing.T, cn string, parent *x509.Certificate, signingKey any) (*x509.Certificate, *ecdsa.PrivateKey) { - var err error - var key *ecdsa.PrivateKey - now := time.Now() - - key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - publicKey := &key.PublicKey - signerPrivateKey := key - if signingKey != nil { - signerPrivateKey = signingKey.(*ecdsa.PrivateKey) - } - - serial, _ := rand.Int(rand.Reader, big.NewInt(1337)) - certTemplate := &x509.Certificate{ - Subject: pkix.Name{CommonName: cn}, - SerialNumber: serial, - BasicConstraintsValid: true, - IsCA: true, - NotBefore: now, - NotAfter: now.Add(time.Hour * 24), - } - - if parent == nil { - parent = certTemplate - } - - certData, err := x509.CreateCertificate(rand.Reader, certTemplate, parent, publicKey, signerPrivateKey) - require.NoError(t, err) - - cert, err := x509.ParseCertificate(certData) - require.NoError(t, err) - - return cert, key -} diff --git a/internal/controller/issuer_controller.go b/internal/controller/issuer_controller.go index aabbb2a..8753771 100644 --- a/internal/controller/issuer_controller.go +++ b/internal/controller/issuer_controller.go @@ -83,7 +83,7 @@ func (r *IssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res } if err := r.Get(ctx, req.NamespacedName, issuer); err != nil { if err := client.IgnoreNotFound(err); err != nil { - return ctrl.Result{}, fmt.Errorf("unexpected get error: %v", err) + return ctrl.Result{}, fmt.Errorf("unexpected get error: %w", err) } log.Info(fmt.Sprintf("%s not found. Ignoring.", issuer.GetObjectKind().GroupVersionKind().Kind)) return ctrl.Result{}, nil @@ -205,8 +205,8 @@ func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer comman return nil, fmt.Errorf("%w, secret name: %s, reason: %w", errGetAuthSecret, issuer.GetSpec().SecretName, err) } - switch { - case authSecret.Type == corev1.SecretTypeOpaque: + switch authSecret.Type { + case corev1.SecretTypeOpaque: // We expect auth credentials for a client credential OAuth2.0 flow if the secret type is opaque tokenURL, ok := authSecret.Data[commandissuer.OAuthTokenURLKey] if !ok { @@ -235,7 +235,7 @@ func commandConfigFromIssuer(ctx context.Context, c client.Client, issuer comman } log.Info("Found oauth client credentials in secret", "commandSecretName", issuer.GetSpec().SecretName, "type", authSecret.Type) - case authSecret.Type == corev1.SecretTypeBasicAuth: + case corev1.SecretTypeBasicAuth: username, ok := authSecret.Data[corev1.BasicAuthUsernameKey] if !ok { return nil, fmt.Errorf("%w: %s", errGetAuthSecret, "found basic auth secret with no username") diff --git a/internal/controller/issuer_controller_test.go b/internal/controller/issuer_controller_test.go index 9e6764d..54fda8b 100644 --- a/internal/controller/issuer_controller_test.go +++ b/internal/controller/issuer_controller_test.go @@ -24,7 +24,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" commandissuer "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" - commandissuerv1alpha1 "github.com/Keyfactor/command-cert-manager-issuer/api/v1alpha1" "github.com/Keyfactor/command-cert-manager-issuer/internal/command" logrtesting "github.com/go-logr/logr/testing" "github.com/stretchr/testify/assert" @@ -71,8 +70,8 @@ func TestIssuerReconcile(t *testing.T) { defaultHealthCheckInterval *time.Duration expectedResult ctrl.Result expectedError error - expectedReadyConditionStatus commandissuerv1alpha1.ConditionStatus - expectedMetadataSupportedConditionStatus commandissuerv1alpha1.ConditionStatus + expectedReadyConditionStatus commandissuer.ConditionStatus + expectedMetadataSupportedConditionStatus commandissuer.ConditionStatus } tests := map[string]testCase{ @@ -80,23 +79,23 @@ func TestIssuerReconcile(t *testing.T) { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -114,31 +113,31 @@ func TestIssuerReconcile(t *testing.T) { }, }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-basicauth-no-username": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -155,30 +154,30 @@ func TestIssuerReconcile(t *testing.T) { }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "issuer-basicauth-no-password": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -195,29 +194,29 @@ func TestIssuerReconcile(t *testing.T) { }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "success-clusterissuer-basicauth": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -236,27 +235,27 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "success-issuer-oauth": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -268,40 +267,40 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Data: map[string][]byte{ - commandissuerv1alpha1.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), - commandissuerv1alpha1.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), - commandissuerv1alpha1.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), - commandissuerv1alpha1.OAuthScopesKey: []byte("read:certificates,write:certificates"), - commandissuerv1alpha1.OAuthAudienceKey: []byte("https://command.example.com"), + commandissuer.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), + commandissuer.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), + commandissuer.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), + commandissuer.OAuthScopesKey: []byte("read:certificates,write:certificates"), + commandissuer.OAuthAudienceKey: []byte("https://command.example.com"), }, }, }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-oauth-no-tokenurl": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -313,38 +312,38 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Data: map[string][]byte{ - commandissuerv1alpha1.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), - commandissuerv1alpha1.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), - commandissuerv1alpha1.OAuthScopesKey: []byte("read:certificates,write:certificates"), - commandissuerv1alpha1.OAuthAudienceKey: []byte("https://command.example.com"), + commandissuer.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), + commandissuer.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), + commandissuer.OAuthScopesKey: []byte("read:certificates,write:certificates"), + commandissuer.OAuthAudienceKey: []byte("https://command.example.com"), }, }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "issuer-oauth-no-clientid": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -356,38 +355,38 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Data: map[string][]byte{ - commandissuerv1alpha1.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), - commandissuerv1alpha1.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), - commandissuerv1alpha1.OAuthScopesKey: []byte("read:certificates,write:certificates"), - commandissuerv1alpha1.OAuthAudienceKey: []byte("https://command.example.com"), + commandissuer.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), + commandissuer.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), + commandissuer.OAuthScopesKey: []byte("read:certificates,write:certificates"), + commandissuer.OAuthAudienceKey: []byte("https://command.example.com"), }, }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "issuer-oauth-no-clientsecret": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -399,33 +398,33 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "ns1", }, Data: map[string][]byte{ - commandissuerv1alpha1.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), - commandissuerv1alpha1.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), - commandissuerv1alpha1.OAuthScopesKey: []byte("read:certificates,write:certificates"), - commandissuerv1alpha1.OAuthAudienceKey: []byte("https://command.example.com"), + commandissuer.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), + commandissuer.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), + commandissuer.OAuthScopesKey: []byte("read:certificates,write:certificates"), + commandissuer.OAuthAudienceKey: []byte("https://command.example.com"), }, }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "success-clusterissuer-oauth": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -437,18 +436,18 @@ func TestIssuerReconcile(t *testing.T) { Namespace: "kube-system", }, Data: map[string][]byte{ - commandissuerv1alpha1.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), - commandissuerv1alpha1.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), - commandissuerv1alpha1.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), - commandissuerv1alpha1.OAuthScopesKey: []byte("read:certificates,write:certificates"), - commandissuerv1alpha1.OAuthAudienceKey: []byte("https://command.example.com"), + commandissuer.OAuthTokenURLKey: []byte("https://dev.idp.com/oauth/token"), + commandissuer.OAuthClientIDKey: []byte("fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ"), + commandissuer.OAuthClientSecretKey: []byte("1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H"), + commandissuer.OAuthScopesKey: []byte("read:certificates,write:certificates"), + commandissuer.OAuthAudienceKey: []byte("https://command.example.com"), }, }, }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "issuer-kind-Unrecognized": { @@ -461,52 +460,52 @@ func TestIssuerReconcile(t *testing.T) { "issuer-missing-secret": { name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, }, }, expectedError: errGetAuthSecret, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "issuer-failing-healthchecker-builder": { name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -526,29 +525,29 @@ func TestIssuerReconcile(t *testing.T) { healthCheckerBuilder: newFakeHealthCheckerBuilder(errors.New("simulated health checker builder error"), nil, false), expectedError: errHealthCheckerBuilder, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "issuer-failing-healthchecker-check": { name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, { - Type: commandissuerv1alpha1.IssuerConditionSupportsMetadata, - Status: commandissuerv1alpha1.ConditionFalse, + Type: commandissuer.IssuerConditionSupportsMetadata, + Status: commandissuer.ConditionFalse, }, }, }, @@ -567,30 +566,30 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, errors.New("simulated health check error"), false), expectedError: errHealthCheckerCheck, - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionFalse, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionFalse, }, "success-custom-healthcheck-interval-issuer": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: true, Interval: to.Ptr(metav1.Duration{Duration: 30 * time.Second}), }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -608,30 +607,30 @@ func TestIssuerReconcile(t *testing.T) { }, }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(30) * time.Second}, }, "success-custom-healthcheck-interval-clusterissuer": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: true, Interval: to.Ptr(metav1.Duration{Duration: 120 * time.Second}), }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -650,29 +649,29 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(120) * time.Second}, }, "success-healthcheck-disabled": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: false, }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -691,30 +690,30 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(0)}, }, "success-no-healthcheck-interval": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: true, Interval: nil, }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -733,30 +732,30 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Minute}, }, "success-nil-healthcheck-interval-defaults": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: true, Interval: nil, }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -775,27 +774,27 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, }, "success-default-healthcheck-interval": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", HealthCheck: nil, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -815,27 +814,27 @@ func TestIssuerReconcile(t *testing.T) { defaultHealthCheckInterval: to.Ptr(time.Duration(2) * time.Minute), healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(2) * time.Minute}, }, "success-nil-healthcheck-defaults": { kind: "ClusterIssuer", name: types.NamespacedName{Name: "clusterissuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.ClusterIssuer{ + &commandissuer.ClusterIssuer{ ObjectMeta: metav1.ObjectMeta{ Name: "clusterissuer1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "clusterissuer1-credentials", HealthCheck: nil, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -854,31 +853,31 @@ func TestIssuerReconcile(t *testing.T) { }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, true), clusterResourceNamespace: "kube-system", - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionTrue, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionTrue, + expectedReadyConditionStatus: commandissuer.ConditionTrue, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionTrue, expectedResult: ctrl.Result{RequeueAfter: time.Duration(60) * time.Second}, }, "error-healthcheck-minimum-value": { kind: "Issuer", name: types.NamespacedName{Namespace: "ns1", Name: "issuer1"}, objects: []client.Object{ - &commandissuerv1alpha1.Issuer{ + &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "issuer1", Namespace: "ns1", }, - Spec: commandissuerv1alpha1.IssuerSpec{ + Spec: commandissuer.IssuerSpec{ SecretName: "issuer1-credentials", - HealthCheck: &commandissuerv1alpha1.HealthCheckConfig{ + HealthCheck: &commandissuer.HealthCheckConfig{ Enabled: true, Interval: to.Ptr(metav1.Duration{Duration: 29 * time.Second}), }, }, - Status: commandissuerv1alpha1.IssuerStatus{ - Conditions: []commandissuerv1alpha1.IssuerCondition{ + Status: commandissuer.IssuerStatus{ + Conditions: []commandissuer.IssuerCondition{ { - Type: commandissuerv1alpha1.IssuerConditionReady, - Status: commandissuerv1alpha1.ConditionUnknown, + Type: commandissuer.IssuerConditionReady, + Status: commandissuer.ConditionUnknown, }, }, }, @@ -896,14 +895,14 @@ func TestIssuerReconcile(t *testing.T) { }, }, healthCheckerBuilder: newFakeHealthCheckerBuilder(nil, nil, false), - expectedReadyConditionStatus: commandissuerv1alpha1.ConditionFalse, - expectedMetadataSupportedConditionStatus: commandissuerv1alpha1.ConditionUnknown, + expectedReadyConditionStatus: commandissuer.ConditionFalse, + expectedMetadataSupportedConditionStatus: commandissuer.ConditionUnknown, expectedResult: ctrl.Result{}, }, } scheme := runtime.NewScheme() - require.NoError(t, commandissuerv1alpha1.AddToScheme(scheme)) + require.NoError(t, commandissuer.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) for name, tc := range tests { @@ -950,8 +949,8 @@ func TestIssuerReconcile(t *testing.T) { require.NoError(t, err) require.NoError(t, fakeClient.Get(context.TODO(), tc.name, issuer)) require.NoError(t, err) - assert.True(t, issuer.GetStatus().HasCondition(commandissuerv1alpha1.IssuerConditionReady, tc.expectedReadyConditionStatus)) - assert.True(t, issuer.GetStatus().HasCondition(commandissuerv1alpha1.IssuerConditionSupportsMetadata, tc.expectedMetadataSupportedConditionStatus)) + assert.True(t, issuer.GetStatus().HasCondition(commandissuer.IssuerConditionReady, tc.expectedReadyConditionStatus)) + assert.True(t, issuer.GetStatus().HasCondition(commandissuer.IssuerConditionSupportsMetadata, tc.expectedMetadataSupportedConditionStatus)) } }) } @@ -960,7 +959,7 @@ func TestIssuerReconcile(t *testing.T) { func TestCommandConfigFromIssuer(t *testing.T) { type testCase struct { name string - issuerSpec commandissuerv1alpha1.IssuerSpec + issuerSpec commandissuer.IssuerSpec secretNamespace string objects []client.Object expectedConfig *command.Config @@ -971,7 +970,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { tests := []testCase{ { name: "success-basic-auth", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1003,7 +1002,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-basic-auth-with-ca-cert-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1047,7 +1046,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-basic-auth-with-ca-cert-secret-with-key", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1093,7 +1092,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-basic-auth-with-ca-cert-configmap", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1136,7 +1135,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-basic-auth-with-ca-cert-configmap-with-key", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1181,7 +1180,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-basic-auth-with-ca-cert-configmap-overwrites-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "auth-secret", @@ -1235,7 +1234,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-oauth-minimal", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "oauth-secret", @@ -1269,7 +1268,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-oauth-with-scopes-and-audience", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", SecretName: "oauth-secret", @@ -1307,7 +1306,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-ambient-credentials-with-scopes", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", Scopes: "scope1,scope2", @@ -1324,7 +1323,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-no-auth-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", APIPath: "/api/v1", }, @@ -1339,7 +1338,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-auth-secret-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "missing-secret", }, @@ -1349,7 +1348,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-ca-secret-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", CaSecretName: "missing-ca-secret", }, @@ -1359,7 +1358,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-ca-secret-key-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", CaSecretName: "ca-secret", CaBundleKey: "ca.crt", @@ -1381,7 +1380,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-ca-configmap-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", CaBundleConfigMapName: "missing-ca-bundle", }, @@ -1391,7 +1390,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-ca-configmap-key-not-found", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", CaBundleConfigMapName: "ca-configmap", CaBundleKey: "ca.crt", @@ -1412,7 +1411,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-basic-auth-no-username", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "auth-secret", }, @@ -1434,7 +1433,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-basic-auth-no-password", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "auth-secret", }, @@ -1456,7 +1455,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-oauth-no-token-url", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "oauth-secret", }, @@ -1479,7 +1478,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-oauth-no-client-id", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "oauth-secret", }, @@ -1502,7 +1501,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-oauth-no-client-secret", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "oauth-secret", }, @@ -1525,7 +1524,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "error-unsupported-secret-type", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "auth-secret", }, @@ -1548,7 +1547,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { }, { name: "success-cluster-scoped-secret-namespace", - issuerSpec: commandissuerv1alpha1.IssuerSpec{ + issuerSpec: commandissuer.IssuerSpec{ Hostname: "https://ca.example.com", SecretName: "auth-secret", }, @@ -1579,7 +1578,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { } scheme := runtime.NewScheme() - require.NoError(t, commandissuerv1alpha1.AddToScheme(scheme)) + require.NoError(t, commandissuer.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) for _, tc := range tests { @@ -1590,7 +1589,7 @@ func TestCommandConfigFromIssuer(t *testing.T) { Build() // Create a minimal issuer with the test spec - issuer := &commandissuerv1alpha1.Issuer{ + issuer := &commandissuer.Issuer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-issuer", Namespace: tc.secretNamespace, diff --git a/policy/deployments.rego b/policy/deployments.rego new file mode 100644 index 0000000..ab0e6cf --- /dev/null +++ b/policy/deployments.rego @@ -0,0 +1,31 @@ +package main + +import rego.v1 + +# Validate that every Deployment container and initContainer effectively runs +# as non-root, either by setting securityContext.runAsNonRoot=true itself or +# by inheriting a pod-level default. +pod_run_as_non_root_default if { + object.get(object.get(input.spec.template.spec, "securityContext", {}), "runAsNonRoot", false) == true +} +container_run_as_non_root(container) if { + object.get(object.get(container, "securityContext", {}), "runAsNonRoot", null) == true +} +container_run_as_non_root(container) if { + object.get(object.get(container, "securityContext", {}), "runAsNonRoot", null) == null + pod_run_as_non_root_default +} + +deny contains msg if { + input.kind == "Deployment" + container := input.spec.template.spec.containers[_] + not container_run_as_non_root(container) + msg := sprintf("Deployment %v container %v must set securityContext.runAsNonRoot to true or inherit it from the pod securityContext", [input.metadata.name, container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := input.spec.template.spec.initContainers[_] + not container_run_as_non_root(container) + msg := sprintf("Deployment %v initContainer %v must set securityContext.runAsNonRoot to true or inherit it from the pod securityContext", [input.metadata.name, container.name]) +} \ No newline at end of file diff --git a/policy/roles.rego b/policy/roles.rego new file mode 100644 index 0000000..00dab38 --- /dev/null +++ b/policy/roles.rego @@ -0,0 +1,58 @@ +package main + +import rego.v1 + +# Roles are namespace-scoped resources. A Role without a namespace will be +# silently defaulted by the API server, which can result in permissions being +# granted in an unintended namespace. Require every Role to declare its +# namespace explicitly so intent is clear. +deny contains msg if { + input.kind == "Role" + not input.metadata.namespace + msg := sprintf("Role %v must have a namespace set", [input.metadata.name]) +} + +# RoleBinding resources, similarly, should be namespace-scoped. +deny contains msg if { + input.kind == "RoleBinding" + not input.metadata.namespace + msg := sprintf("RoleBinding %v must have a namespace set", [input.metadata.name]) +} + +# ClusterRole resources must not have a namespace applied. This is typically ignored, but good hygiene +# to omit to avoid confusion. +deny contains msg if { + input.kind == "ClusterRole" + input.metadata.namespace + msg := sprintf("ClusterRole %v must not have a namespace set", [input.metadata.name]) +} + +# ClusterRoleBinding resources must not have a namespace applied. This is typically ignored, but good hygiene +# to omit to avoid confusion. +deny contains msg if { + input.kind == "ClusterRoleBinding" + input.metadata.namespace + msg := sprintf("ClusterRoleBinding %v must not have a namespace set", [input.metadata.name]) +} + +# A ClusterRoleBinding must not be bound to a Role resource +deny contains msg if { + input.kind == "ClusterRoleBinding" + input.roleRef.kind == "Role" + msg := sprintf("ClusterRoleBinding %v must reference a ClusterRole, not a Role", [input.metadata.name]) +} + +# A RoleBinding must not be bound to a ClusterRole resource +deny contains msg if { + input.kind == "RoleBinding" + input.roleRef.kind == "ClusterRole" + msg := sprintf("RoleBinding %v must reference a Role, not a ClusterRole", [input.metadata.name]) +} + +deny contains msg if { + input.kind in {"RoleBinding", "ClusterRoleBinding"} + subject := input.subjects[_] + subject.kind == "ServiceAccount" + not subject.namespace + msg := sprintf("%v %v has ServiceAccount subject %v without a namespace", [input.kind, input.metadata.name, subject.name]) +} \ No newline at end of file diff --git a/policy/serviceaccounts.rego b/policy/serviceaccounts.rego new file mode 100644 index 0000000..79b7d6a --- /dev/null +++ b/policy/serviceaccounts.rego @@ -0,0 +1,13 @@ +package main + +import rego.v1 + +# ServiceAccounts are namespace-scoped resources. A ServiceAccount without a namespace will be +# silently defaulted by the API server, which can result in permissions being +# granted in an unintended namespace. Require every ServiceAccount to declare its +# namespace explicitly so intent is clear. +deny contains msg if { + input.kind == "ServiceAccount" + not input.metadata.namespace + msg := sprintf("ServiceAccount %v must have a namespace set", [input.metadata.name]) +} \ No newline at end of file From 839dbd2c5de5f8892e8575cb67a009e513d0ee9a Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Wed, 20 May 2026 20:04:47 -0400 Subject: [PATCH 4/4] v2.5.3: Patch vulnerable dependencies (#68) * feat: release 2.5.0 (#62) 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation Co-authored-by: Matthew H. Irby * Merge 2.5.1 to main (#65) * feat: release 2.5.0 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation * release: 2.5.1 * feat: release 2.5.0 (#62) 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation Co-authored-by: Matthew H. Irby * feat: add client caching to reduce OAuth token requests Previously, every certificate request reconciliation created a new Command API client, which meant a new OAuth token was fetched for each request. For customers with OAuth provider quotas, this caused rate limiting issues. This change introduces a ClientCache that: - Caches Command API clients by configuration hash - Reuses cached clients across reconciliations for the same issuer - Allows the underlying oauth2 library's token caching to work as intended - Is thread-safe for concurrent reconciliations The cache key is a SHA-256 hash of all configuration fields that affect the client connection (hostname, API path, credentials, scopes, etc.), ensuring different issuers get different clients while the same issuer reuses its client. Fixes: OAuth token re-authentication on every request Co-Authored-By: Claude Opus 4.5 * chore(scripts): update scripting usability * feat: update keyfactor-auth-client-go to v1.3.1 Signed-off-by: Matthew H. Irby * chore: remove test short circuit Signed-off-by: Matthew H. Irby * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Revert "Potential fix for pull request finding" This reverts commit 19bc19be8aa93166d4e628c5eb6bf788ea3dd797. * chore: cleanup Signed-off-by: Matthew H. Irby * chore: break build & test into its own workflow Signed-off-by: Matthew H. Irby * fix: remove lint from CI Signed-off-by: Matthew H. Irby * chore(docs): update CHANGELOG Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Matthew H. Irby Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew H. Irby Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Matthew H. Irby Co-authored-by: Matthew H. Irby Co-authored-by: spb <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew H. Irby Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Merge 2.5.2 to main (#67) * feat: release 2.5.0 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation * release: 2.5.1 * feat: release 2.5.0 (#62) 2.5.0: CA Bundle with ConfigMap + GKE Ambient Credentials Documentation Co-authored-by: Matthew H. Irby * feat: add client caching to reduce OAuth token requests Previously, every certificate request reconciliation created a new Command API client, which meant a new OAuth token was fetched for each request. For customers with OAuth provider quotas, this caused rate limiting issues. This change introduces a ClientCache that: - Caches Command API clients by configuration hash - Reuses cached clients across reconciliations for the same issuer - Allows the underlying oauth2 library's token caching to work as intended - Is thread-safe for concurrent reconciliations The cache key is a SHA-256 hash of all configuration fields that affect the client connection (hostname, API path, credentials, scopes, etc.), ensuring different issuers get different clients while the same issuer reuses its client. Fixes: OAuth token re-authentication on every request Co-Authored-By: Claude Opus 4.5 * chore(scripts): update scripting usability * feat: update keyfactor-auth-client-go to v1.3.1 Signed-off-by: Matthew H. Irby * chore: remove test short circuit Signed-off-by: Matthew H. Irby * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Revert "Potential fix for pull request finding" This reverts commit 19bc19be8aa93166d4e628c5eb6bf788ea3dd797. * chore: cleanup Signed-off-by: Matthew H. Irby * chore: break build & test into its own workflow Signed-off-by: Matthew H. Irby * fix: remove lint from CI Signed-off-by: Matthew H. Irby * chore(docs): update CHANGELOG Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Matthew H. Irby Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew H. Irby Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Add missing namespace specification + address linting issues (#66) * fix: log errors from Enrollment API call Signed-off-by: Matthew H. Irby * fix: add missing namespaces, add linting to catch issues Signed-off-by: Matthew H. Irby * feat: add linting Signed-off-by: Matthew H. Irby * chore: address lint issues Signed-off-by: Matthew H. Irby * chore: update CHANGELOG Signed-off-by: Matthew H. Irby * chore: apply copilot feedback Signed-off-by: Matthew H. Irby * feat: fix typo Signed-off-by: Matthew H. Irby * feat: copilot suggestions Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby Co-authored-by: Matthew H. Irby Co-authored-by: spb <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew H. Irby Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore(deps): patch vulnerable dependencies Signed-off-by: Matthew H. Irby * fix(tests): fix test failures caused by merge conflict resolution issue Signed-off-by: Matthew H. Irby * chore(docs): update CHANGELOG Signed-off-by: Matthew H. Irby * chore(ci): update trigger for dependency review Signed-off-by: Matthew H. Irby * chore(ci): address copilot feedback Signed-off-by: Matthew H. Irby --------- Signed-off-by: Matthew H. Irby Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: spb <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 15 ++++++- CHANGELOG.md | 7 +++ Makefile | 4 ++ go.mod | 34 +++++++-------- go.sum | 89 ++++++++++++++++++++------------------ internal/command/client.go | 3 +- 6 files changed, 90 insertions(+), 62 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f822e77..a843b9d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,4 +71,17 @@ jobs: (echo; echo "Unexpected difference in directories after code generation. Run 'make generate manifests' and commit."; exit 1) - name: Lint Helm manifests - run: make lint-manifests \ No newline at end of file + run: make lint-manifests + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Dependency Review + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: critical diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e444c..fb8ab7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# v2.5.3 +## Security +- Updated dependencies to address various security vulnerabilities: + - google.golang.org/grpc -> v1.79.3 (CVE-2026-33186) + - github.com/Azure/go-ntlmssp -> v0.1.1 (CVE-2026-32952) + - golang.org/x/crypto -> v0.46.0 (CVE-2025-58181 & CVE-2025-47914) + # 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/Makefile b/Makefile index 5a3b40d..caf2534 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,10 @@ lint: golangci-lint ## Run golangci-lint linter & yamllint lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes $(GOLANGCI_LINT) run --fix +.PHONY: fix +fix: + go fix ./... + .PHONY: helm-template helm-template: ## Render Helm chart templates to stdout (includes CRDs). helm template $(HELM_RELEASE_NAME) $(HELM_CHART_DIR) --include-crds diff --git a/go.mod b/go.mod index d2f1d31..0dcacbc 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ require ( github.com/Keyfactor/keyfactor-auth-client-go v1.3.1 github.com/Keyfactor/keyfactor-go-client-sdk/v25 v25.0.2 github.com/cert-manager/cert-manager v1.16.2 - github.com/go-logr/logr v1.4.2 - github.com/stretchr/testify v1.10.0 - golang.org/x/oauth2 v0.30.0 + github.com/go-logr/logr v1.4.3 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.34.0 k8s.io/api v0.31.1 k8s.io/apimachinery v0.31.1 k8s.io/client-go v0.31.1 @@ -24,23 +24,23 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 // indirect - google.golang.org/grpc v1.70.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect ) require ( - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -86,16 +86,16 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.41.0 - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/net v0.48.0 + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.10.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/api v0.223.0 - google.golang.org/protobuf v1.36.5 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 907e229..bf30c03 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -16,8 +16,9 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1 h1:mrkD github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1/go.mod h1:hPv41DbqMmnxcGralanA/kVlfdH5jv3T4LxGku2E1BY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= @@ -61,8 +62,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.6/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkPro github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -168,8 +169,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -183,29 +184,29 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -219,8 +220,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -239,17 +240,17 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -262,24 +263,24 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -288,22 +289,24 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc= google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/command/client.go b/internal/command/client.go index 416ebb7..28fe7e9 100644 --- a/internal/command/client.go +++ b/internal/command/client.go @@ -23,13 +23,14 @@ import ( "strings" "time" + "context" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" v1 "github.com/Keyfactor/keyfactor-go-client-sdk/v25/api/keyfactor/v1" "github.com/go-logr/logr" "github.com/golang-jwt/jwt/v5" - "golang.org/x/net/context" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/idtoken"