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) + } + }) + } +}