From 648e0e45e55a59f744c6209ab4a805f0e82a77f8 Mon Sep 17 00:00:00 2001 From: sabsari Date: Tue, 11 Aug 2026 22:40:05 +0900 Subject: [PATCH] Add JWT-SVID exec-auth source for kubeConfigs entries (#907) Add jwtSVIDExec as a fourth exactly-one kubeConfigs source: the chart generates an exec-credential kubeconfig that authenticates to an external cluster with short-lived SPIFFE JWT-SVIDs instead of a static credential. Signed-off-by: sabsari Co-authored-by: Claude Opus 4.8 --- charts/spire/charts/spire-server/README.md | 6 ++ .../_controller-manager-container.tpl | 8 +++ .../spire-server/templates/_helpers.tpl | 57 +++++++++++++++++++ .../templates/kubeconfig-secret.yaml | 5 +- .../templates/server-resource.yaml | 23 +++++++- charts/spire/charts/spire-server/values.yaml | 31 +++++++++- tests/unit/spire_test.go | 15 +++++ 7 files changed, 140 insertions(+), 5 deletions(-) diff --git a/charts/spire/charts/spire-server/README.md b/charts/spire/charts/spire-server/README.md index d02aa30..ba80fab 100644 --- a/charts/spire/charts/spire-server/README.md +++ b/charts/spire/charts/spire-server/README.md @@ -634,5 +634,11 @@ In order to run Tornjak with simple HTTP Connection only, make sure you don't cr | `tests.bash.image.pullPolicy` | The image pull policy | `IfNotPresent` | | `tests.bash.image.tag` | Overrides the image tag whose default is the chart appVersion | `latest@sha256:90041f375e30f41aa7e0390075d8a69dc61900771d52fa98d63ee5d03d866a58` | | `kubeConfigs` | Manage additional kubeconfig files to talk to external Kubernetes clusters | `{}` | +| `jwtSVIDExecConfig.image.registry` | The OCI registry to pull the exec credential plugin image from | `ghcr.io` | +| `jwtSVIDExecConfig.image.repository` | The repository within the registry | `spiffe/k8s-spiffe-workload-jwt-exec-auth` | +| `jwtSVIDExecConfig.image.pullPolicy` | The image pull policy | `IfNotPresent` | +| `jwtSVIDExecConfig.image.tag` | Overrides the image tag | `0.2.0` | +| `jwtSVIDExecConfig.pluginPath` | The path of the plugin binary inside the plugin image, staged for exec by the kubeConfigs consumers | `/ko-app/cmd` | +| `jwtSVIDExecConfig.spiffeID` | The SPIFFE ID the plugin mints a JWT-SVID for; a full spiffe:// URI, or a "/"-prefixed path expanded with the chart trust domain (e.g. /spire-root); required when any kubeConfigs entry uses jwtSVIDExec | `/spire-root` | | `spireIdentityExchange.enabled` | Enable the server side of the SPIRE Identity Exchange system | `false` | | `spike.enabled` | Enable the server side of SPIKE | `false` | diff --git a/charts/spire/charts/spire-server/templates/_controller-manager-container.tpl b/charts/spire/charts/spire-server/templates/_controller-manager-container.tpl index 177c593..14cc911 100644 --- a/charts/spire/charts/spire-server/templates/_controller-manager-container.tpl +++ b/charts/spire/charts/spire-server/templates/_controller-manager-container.tpl @@ -173,6 +173,14 @@ Auto-generation preserves trailing numbers from cluster names or uses hash for u subPath: {{ . }} readOnly: true {{- end }} + {{- if hasKey . "kubeConfig" }} + {{- $entry := default dict (index .Values.kubeConfigs .kubeConfig) }} + {{- if hasKey $entry "jwtSVIDExec" }} + - name: plugins + mountPath: /plugins + readOnly: true + {{- end }} + {{- end }} - name: spire-controller-manager-tmp mountPath: /tmp subPath: {{ printf "spire-controller-manager%s" .suffix }} diff --git a/charts/spire/charts/spire-server/templates/_helpers.tpl b/charts/spire/charts/spire-server/templates/_helpers.tpl index 42370b6..e2c0019 100644 --- a/charts/spire/charts/spire-server/templates/_helpers.tpl +++ b/charts/spire/charts/spire-server/templates/_helpers.tpl @@ -186,6 +186,63 @@ Name of the chart-generated Secret holding the inline kubeConfigs entries. {{ include "spire-server.fullname" . }}-kubeconfigs {{- end }} +{{/* +Path of the staged jwt-svid exec plugin binary inside the shared plugins volume. Used both as the +init-container copy target and as the exec kubeconfig command, so the two must stay in sync. +*/}} +{{- define "spire-server.jwt-svid-exec-binary-path" -}} +/plugins/jwt-svid-exec +{{- end }} + +{{- define "spire-server.jwt-svid-exec-kubeconfig" -}} +{{- $jwtSVIDExec := .jwtSVIDExec -}} +{{- $root := .root -}} +{{- $spiffeID := $root.Values.jwtSVIDExecConfig.spiffeID -}} +{{- if not $spiffeID -}} +{{- fail "jwtSVIDExecConfig.spiffeID is required when a kubeConfigs entry uses jwtSVIDExec" -}} +{{- end -}} +{{- $chartTD := include "spire-lib.trust-domain" $root -}} +{{- if hasPrefix "/" $spiffeID -}} +{{- $spiffeID = printf "spiffe://%s%s" $chartTD $spiffeID -}} +{{- else if hasPrefix "spiffe://" $spiffeID -}} +{{- $idTD := $spiffeID | trimPrefix "spiffe://" | splitList "/" | first -}} +{{- if ne $idTD $chartTD -}} +{{- fail (printf "jwtSVIDExecConfig.spiffeID trust domain %q must match the chart trust domain %q" $idTD $chartTD) -}} +{{- end -}} +{{- else -}} +{{- fail (printf "jwtSVIDExecConfig.spiffeID %q must be a spiffe:// URI or a path starting with \"/\"" $spiffeID) -}} +{{- end -}} +apiVersion: v1 +kind: Config +clusters: +- name: cluster + cluster: + server: {{ $jwtSVIDExec.server | quote }} + certificate-authority-data: {{ $jwtSVIDExec.certificateAuthorityData | quote }} +users: +- name: spiffe + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: {{ include "spire-server.jwt-svid-exec-binary-path" $root }} + interactiveMode: Never + env: + - name: SPIFFE_JWT_SOURCE + value: "server-admin-api" + - name: SPIRE_SERVER_SOCKET + value: "unix:///tmp/spire-server/private/api.sock" + - name: SPIFFE_ID + value: {{ $spiffeID | quote }} + - name: SPIFFE_JWT_AUDIENCE + value: {{ $jwtSVIDExec.audience | default "k8s" | quote }} +contexts: +- name: cluster + context: + cluster: cluster + user: spiffe +current-context: cluster +{{- end }} + {{- define "spire-server.serviceAccountAllowedList" }} {{- $releaseNamespace := include "spire-server.agent-namespace" . }} {{- if ne (len .Values.nodeAttestor.k8sPSAT.serviceAccountAllowList) 0 }} diff --git a/charts/spire/charts/spire-server/templates/kubeconfig-secret.yaml b/charts/spire/charts/spire-server/templates/kubeconfig-secret.yaml index eb87c6e..0a406e9 100644 --- a/charts/spire/charts/spire-server/templates/kubeconfig-secret.yaml +++ b/charts/spire/charts/spire-server/templates/kubeconfig-secret.yaml @@ -6,8 +6,9 @@ {{- if hasKey $value "kubeConfig" }}{{ $present = append $present "kubeConfig" }}{{- end }} {{- if hasKey $value "kubeConfigBase64" }}{{ $present = append $present "kubeConfigBase64" }}{{- end }} {{- if hasKey $value "externalSecret" }}{{ $present = append $present "externalSecret" }}{{- end }} +{{- if hasKey $value "jwtSVIDExec" }}{{ $present = append $present "jwtSVIDExec" }}{{- end }} {{- if ne (len $present) 1 }} -{{- fail (printf "kubeConfigs entry %q must set exactly one of kubeConfig, kubeConfigBase64, or externalSecret (got: %v)" $name $present) }} +{{- fail (printf "kubeConfigs entry %q must set exactly one of kubeConfig, kubeConfigBase64, externalSecret, or jwtSVIDExec (got: %v)" $name $present) }} {{- end }} {{- if hasKey $value "externalSecret" }} {{- if not $value.externalSecret.name }} @@ -30,6 +31,8 @@ data: {{- range $name, $value := $inline }} {{- if hasKey $value "kubeConfig" }} {{ $name }}: {{ $value.kubeConfig | b64enc }} + {{- else if hasKey $value "jwtSVIDExec" }} + {{ $name }}: {{ include "spire-server.jwt-svid-exec-kubeconfig" (dict "jwtSVIDExec" $value.jwtSVIDExec "root" $root) | b64enc }} {{- else }} {{ $name }}: {{ $value.kubeConfigBase64 | nospace }} {{- end }} diff --git a/charts/spire/charts/spire-server/templates/server-resource.yaml b/charts/spire/charts/spire-server/templates/server-resource.yaml index 1a5b969..be1a6c7 100644 --- a/charts/spire/charts/spire-server/templates/server-resource.yaml +++ b/charts/spire/charts/spire-server/templates/server-resource.yaml @@ -65,7 +65,11 @@ {{- end }} {{- end }} {{- $pluginsToLoad := include "spire-lib.extract_custom_plugin_images" . | fromYamlArray }} -{{- $pluginLoaderNeeded := or .Values.credentialComposer.cel.enabled .Values.spireIdentityExchange.enabled (gt (len $pluginsToLoad) 0) }} +{{- $jwtExecNeeded := false }} +{{- range $name, $value := .Values.kubeConfigs }} +{{- if hasKey $value "jwtSVIDExec" }}{{ $jwtExecNeeded = true }}{{- end }} +{{- end }} +{{- $pluginLoaderNeeded := or .Values.credentialComposer.cel.enabled .Values.spireIdentityExchange.enabled (gt (len $pluginsToLoad) 0) $jwtExecNeeded }} {{- if not .Values.externalServer }} apiVersion: apps/v1 {{- if eq .Values.kind "statefulset" }} @@ -176,6 +180,23 @@ spec: mountPath: /plugins imagePullPolicy: {{ .Values.credentialComposer.spireIdentityExchange.image.pullPolicy }} {{- end }} + {{- if $jwtExecNeeded }} + - name: init-jwt-svid-exec + securityContext: + {{- include "spire-lib.securitycontext" . | nindent 12 }} + image: {{ template "spire-lib.image" (dict "appVersion" $.Chart.AppVersion "image" .Values.jwtSVIDExecConfig.image "global" .Values.global) }} + # Use the previously copied busybox to stage the exec credential plugin binary where the kubeConfigs consumers can fork it. + command: + - /plugins/busybox + - sh + - -ec + - | + /plugins/busybox cp -a {{ .Values.jwtSVIDExecConfig.pluginPath }} {{ include "spire-server.jwt-svid-exec-binary-path" . }} + volumeMounts: + - name: plugins + mountPath: /plugins + imagePullPolicy: {{ .Values.jwtSVIDExecConfig.image.pullPolicy }} + {{- end }} {{- range $idx, $plugin := $pluginsToLoad }} - name: {{ printf "init-plugin-%d" $idx }} securityContext: diff --git a/charts/spire/charts/spire-server/values.yaml b/charts/spire/charts/spire-server/values.yaml index 175bb41..da39180 100644 --- a/charts/spire/charts/spire-server/values.yaml +++ b/charts/spire/charts/spire-server/values.yaml @@ -1622,9 +1622,11 @@ tests: tag: latest@sha256:90041f375e30f41aa7e0390075d8a69dc61900771d52fa98d63ee5d03d866a58 ## @param kubeConfigs [object] Manage additional kubeconfig files to talk to external Kubernetes clusters -## Each entry sets exactly one of kubeConfig, kubeConfigBase64, or externalSecret. Use externalSecret to -## reference a kubeconfig from an externally-managed Secret instead of embedding it in values; -## entries may reference different Secrets and mix with inline ones. +## Each entry sets exactly one of kubeConfig, kubeConfigBase64, externalSecret, or jwtSVIDExec. Use externalSecret +## to reference a kubeconfig from an externally-managed Secret instead of embedding it in values; entries may +## reference different Secrets and mix with inline ones. Use jwtSVIDExec to have the chart generate an +## exec-credential kubeconfig that authenticates to the target cluster with short-lived SPIFFE JWT-SVIDs fetched +## at call time by the exec plugin (see jwtSVIDExecConfig). kubeConfigs: {} # clustera: # kubeConfig: | @@ -1636,6 +1638,29 @@ kubeConfigs: {} # externalSecret: # name: my-kubeconfigs-secret # name of the externally-managed Secret to read from # key: clusterc # optional, defaults to the entry name +# clusterd: +# jwtSVIDExec: +# server: https://clusterd-api.example.com:6443 # target apiserver URL +# certificateAuthorityData: LS0tLS1CRUdJ... # apiserver CA bundle, base64-encoded PEM (kubeconfig certificate-authority-data) +# audience: k8s # optional, JWT-SVID audience the target expects (default k8s) + +## @param jwtSVIDExecConfig.image.registry The OCI registry to pull the exec credential plugin image from +## @param jwtSVIDExecConfig.image.repository The repository within the registry +## @param jwtSVIDExecConfig.image.pullPolicy The image pull policy +## @param jwtSVIDExecConfig.image.tag Overrides the image tag +## @param jwtSVIDExecConfig.pluginPath The path of the plugin binary inside the plugin image, staged for exec by the kubeConfigs consumers +## @param jwtSVIDExecConfig.spiffeID The SPIFFE ID the plugin mints a JWT-SVID for; a full spiffe:// URI, or a "/"-prefixed path expanded with the chart trust domain (e.g. /spire-root); required when any kubeConfigs entry uses jwtSVIDExec +## Global wiring shared by every kubeConfigs entry that uses jwtSVIDExec. The plugin binary is staged from this +## image into the shared plugins volume, and every such entry mints a JWT-SVID for spiffeID from the SPIRE Server +## admin API socket, which is already mounted into the kubeConfigs consumers, so no agent Workload API socket is required. +jwtSVIDExecConfig: + image: + registry: ghcr.io + repository: spiffe/k8s-spiffe-workload-jwt-exec-auth + pullPolicy: IfNotPresent + tag: "0.2.0" + pluginPath: /ko-app/cmd + spiffeID: "/spire-root" spireIdentityExchange: ## @param spireIdentityExchange.enabled Enable the server side of the SPIRE Identity Exchange system diff --git a/tests/unit/spire_test.go b/tests/unit/spire_test.go index eb6cabf..24964e3 100644 --- a/tests/unit/spire_test.go +++ b/tests/unit/spire_test.go @@ -333,6 +333,21 @@ spire-server: Expect(objs[serverTmpl]).Should(ContainSubstring("name: my-ext-secret")) Expect(objs[serverTmpl]).Should(ContainSubstring("path: clusterb")) }) + It("jwtSVIDExec entry generates the Secret and stages the exec plugin", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + jwtSVIDExecConfig: + spiffeID: spiffe://example.org/external-spire-server + kubeConfigs: + clusterd: + jwtSVIDExec: + server: https://clusterd-api.example.com:6443 + certificateAuthorityData: TESTCADATAB64== +`) + Expect(err).Should(Succeed()) + Expect(objs[secretTmpl]).Should(ContainSubstring("kind: Secret")) + Expect(objs[serverTmpl]).Should(ContainSubstring("init-jwt-svid-exec")) + }) }) Describe("spire-server.externalServerSubject", func() { It("binds the external server's downstream RBAC to a ServiceAccount subject", func() {