diff --git a/go.mod b/go.mod index 76cec08..937a52b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cofide/spiffe-enable -go 1.25.5 +go 1.25 require ( github.com/evanphx/json-patch v5.9.11+incompatible diff --git a/internal/const/const.go b/internal/const/const.go index 6b35293..b1b1f3c 100644 --- a/internal/const/const.go +++ b/internal/const/const.go @@ -2,9 +2,13 @@ package constants // Pod annotations const ( - InjectAnnotation = "spiffe.cofide.io/inject" - DebugAnnotation = "spiffe.cofide.io/debug" - EnvoyLogLevelAnnotation = "spiffe.cofide.io/envoy-log-level" + InjectAnnotation = "spiffe.cofide.io/inject" + DebugAnnotation = "spiffe.cofide.io/debug" + EnvoyLogLevelAnnotation = "spiffe.cofide.io/envoy-log-level" + HelperJWTAudienceAnnotation = "spiffe.io/helper-jwt-audience" + HelperJWTFilenameAnnotation = "spiffe.io/helper-jwt-filename" + HelperJWTExtraAudiencesAnnotation = "spiffe.io/helper-jwt-extra-audiences" + HelperJWTSVIDFileModeAnnotation = "spiffe.io/helper-jwt-svid-file-mode" ) // Components that can be injected diff --git a/internal/helper/config.go b/internal/helper/config.go index d5d9faf..ce03845 100644 --- a/internal/helper/config.go +++ b/internal/helper/config.go @@ -7,16 +7,18 @@ import ( constants "github.com/cofide/spiffe-enable/internal/const" "github.com/cofide/spiffe-enable/internal/workload" "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" "github.com/hashicorp/hcl/v2/gohcl" ) // Images var ( - SPIFFEHelperImage = "ghcr.io/spiffe/spiffe-helper:0.10.1" - InitHelperImage = "ghcr.io/cofide/spiffe-enable-init:v0.3.0" + SPIFFEHelperImage = "ghcr.io/spiffe/spiffe-helper:0.10.0" + InitHelperImage = "ghcr.io/cofide/spiffe-enable-init:v0.5.2" ) // Constants @@ -58,13 +60,12 @@ type SPIFFEHelperConfig struct { SVIDBundleFilename string `hcl:"svid_bundle_file_name"` // JWT configuration - JWTSVIDs []SPIFFEHelperJWTConfig `hcl:"jwt_svids,block"` - JWTBundleFilename string `hcl:"jwt_bundle_file_name"` + JWTBundleFilename string `hcl:"jwt_bundle_file_name"` } type SPIFFEHelperJWTConfig struct { JWTAudience string `hcl:"jwt_audience"` - JWTExtraAudiences []string `hcl:"jwt_extra_audiences"` + JWTExtraAudiences []string `hcl:"jwt_extra_audiences,optional"` JWTSVIDFilename string `hcl:"jwt_svid_file_name"` } @@ -79,6 +80,26 @@ type SPIFFEHelperConfigParams struct { AgentAddress string CertPath string IncludeIntermediateBundle bool + JWTConfigs []SPIFFEHelperJWTConfig + JWTSVIDFileMode int +} + +func jwtSVIDConfigToCtyValue(jwtConfig SPIFFEHelperJWTConfig) cty.Value { + objMap := map[string]cty.Value{ + "jwt_audience": cty.StringVal(jwtConfig.JWTAudience), + "jwt_svid_file_name": cty.StringVal(jwtConfig.JWTSVIDFilename), + } + + // Only add jwt_extra_audiences if it has values (to avoid `null` in generated HCL). + if len(jwtConfig.JWTExtraAudiences) > 0 { + extraAuds := make([]cty.Value, len(jwtConfig.JWTExtraAudiences)) + for j, aud := range jwtConfig.JWTExtraAudiences { + extraAuds[j] = cty.StringVal(aud) + } + objMap["jwt_extra_audiences"] = cty.ListVal(extraAuds) + } + + return cty.ObjectVal(objMap) } func NewSPIFFEHelper(params SPIFFEHelperConfigParams) (*SPIFFEHelper, error) { @@ -86,6 +107,11 @@ func NewSPIFFEHelper(params SPIFFEHelperConfigParams) (*SPIFFEHelper, error) { return nil, fmt.Errorf("missing spiffe-helper configuration parameters") } + jwtSVIDFileMode := params.JWTSVIDFileMode + if jwtSVIDFileMode == 0 { + jwtSVIDFileMode = defaultJWTSVIDFileMode + } + spiffeHelperCfg := &SPIFFEHelperConfig{ CertDir: params.CertPath, DaemonMode: BoolPtr(true), @@ -95,14 +121,33 @@ func NewSPIFFEHelper(params SPIFFEHelperConfigParams) (*SPIFFEHelper, error) { SVIDFilename: "tls.crt", SVIDKeyFilename: "tls.key", SVIDBundleFilename: "ca.pem", + JWTSVIDFileMode: jwtSVIDFileMode, HealthCheck: SPIFFEHelperHealthConfig{ ListenerEnabled: true, + BindPort: SPIFFEHelperHealthCheckPort, + LivenessPath: SPIFFEHelperHealthCheckLivenessPath, + ReadinessPath: SPIFFEHelperHealthCheckReadinessPath, }, } - // Marshal to an HCL-formatted string + // Marshal base config to HCL hclFile := hclwrite.NewEmptyFile() gohcl.EncodeIntoBody(spiffeHelperCfg, hclFile.Body()) + + // Only add JWT SVIDs configuration if present + if len(params.JWTConfigs) > 0 { + body := hclFile.Body() + + // Build a list of JWT SVID objects + jwtObjects := make([]cty.Value, len(params.JWTConfigs)) + for i, jwtConfig := range params.JWTConfigs { + jwtObjects[i] = jwtSVIDConfigToCtyValue(jwtConfig) + } + + // Set jwt_svids as a list attribute + body.SetAttributeValue("jwt_svids", cty.ListVal(jwtObjects)) + } + hclBytes := hclFile.Bytes() hclString := string(hclBytes) @@ -201,6 +246,16 @@ func (h *SPIFFEHelper) GetInitContainer() corev1.Container { Name: SPIFFEHelperConfigContentEnvVar, Value: h.Config, }}, + // Some workloads enforce `runAsNonRoot: true` at the Pod level (e.g. cert-manager). + // Ensure our init container complies; it only writes into EmptyDir volumes and does not need root. + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + RunAsUser: ptr.To(int64(65532)), + RunAsGroup: ptr.To(int64(65532)), + RunAsNonRoot: ptr.To(true), + Privileged: ptr.To(false), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"all"}}, + }, VolumeMounts: []corev1.VolumeMount{ { Name: SPIFFEHelperConfigVolumeName, MountPath: filepath.Dir(configFilePath), diff --git a/internal/helper/config_test.go b/internal/helper/config_test.go index 2eff89d..f5b5006 100644 --- a/internal/helper/config_test.go +++ b/internal/helper/config_test.go @@ -1,6 +1,7 @@ package helper import ( + "strings" "testing" "github.com/hashicorp/hcl/v2/hclsimple" @@ -31,10 +32,23 @@ func TestNewSPIFFEHelper(t *testing.T) { "SVIDFilename": `svid_file_name = "tls.crt"`, "SVIDKeyFilename": `svid_key_file_name = "tls.key"`, "SVIDBundleFilename": `svid_bundle_file_name = "ca.pem"`, + "JWTSVIDFileMode": `jwt_svid_file_mode = 600`, "HealthCheckEnabled": `listener_enabled = true`, }, expectError: false, }, + { + name: "custom jwt svid file mode", + params: SPIFFEHelperConfigParams{ + AgentAddress: "/tmp/agent.sock", + CertPath: "/mnt/certs", + JWTSVIDFileMode: 644, + }, + expectedHCLSubstrings: map[string]string{ + "JWTSVIDFileMode": `jwt_svid_file_mode = 644`, + }, + expectError: false, + }, { name: "with inc intermediate bundle", params: SPIFFEHelperConfigParams{ @@ -79,6 +93,7 @@ func TestNewSPIFFEHelper(t *testing.T) { require.NoError(t, err) require.NotNil(t, helper) require.NotEmpty(t, helper.Config) + require.False(t, strings.Contains(helper.Config, "null"), "generated config must not contain `null`: %s", helper.Config) // Parse the generated HCL string back into the SPIFFEHelperConfig struct var decodedCfg SPIFFEHelperConfig @@ -100,6 +115,12 @@ func TestNewSPIFFEHelper(t *testing.T) { assert.Equal(t, "tls.key", decodedCfg.SVIDKeyFilename) assert.Equal(t, "ca.pem", decodedCfg.SVIDBundleFilename) + if tt.params.JWTSVIDFileMode == 0 { + assert.Equal(t, 600, decodedCfg.JWTSVIDFileMode) + } else { + assert.Equal(t, tt.params.JWTSVIDFileMode, decodedCfg.JWTSVIDFileMode) + } + assert.True(t, decodedCfg.HealthCheck.ListenerEnabled) }) } diff --git a/internal/helper/jwt.go b/internal/helper/jwt.go new file mode 100644 index 0000000..0184f62 --- /dev/null +++ b/internal/helper/jwt.go @@ -0,0 +1,94 @@ +package helper + +import ( + "fmt" + "strconv" + "strings" + + constants "github.com/cofide/spiffe-enable/internal/const" + corev1 "k8s.io/api/core/v1" +) + +const defaultJWTSVIDFileMode = 600 + +// ParseJWTConfigFromAnnotations extracts JWT SVID configuration from pod annotations +func ParseJWTConfigFromAnnotations(annotations map[string]string) []SPIFFEHelperJWTConfig { + var jwtConfigs []SPIFFEHelperJWTConfig + + audience, hasAudience := annotations[constants.HelperJWTAudienceAnnotation] + filename, hasFilename := annotations[constants.HelperJWTFilenameAnnotation] + + // Only create JWT config if audience is specified + if !hasAudience || audience == "" { + return jwtConfigs + } + + // Default filename if not specified + if !hasFilename || filename == "" { + filename = "tokens/token" + } + + jwtConfig := SPIFFEHelperJWTConfig{ + JWTAudience: audience, + JWTSVIDFilename: filename, + // Keep this non-nil so empty values are consistently treated as an empty list + // (and so future encoding paths don't accidentally emit `null`). + JWTExtraAudiences: []string{}, + } + + // Parse extra audiences if present (comma-separated) + if extraAudiences, hasExtra := annotations[constants.HelperJWTExtraAudiencesAnnotation]; hasExtra && extraAudiences != "" { + rawAudiences := strings.Split(extraAudiences, ",") + audiences := make([]string, 0, len(rawAudiences)) + for _, a := range rawAudiences { + a = strings.TrimSpace(a) + if a == "" { + continue + } + audiences = append(audiences, a) + } + jwtConfig.JWTExtraAudiences = audiences + } + + jwtConfigs = append(jwtConfigs, jwtConfig) + return jwtConfigs +} + +// ParseJWTSVIDFileModeFromAnnotations extracts a numeric file mode for JWT SVID output. +// +// If not set, returns 600 (owner read/write). +func ParseJWTSVIDFileModeFromAnnotations(annotations map[string]string) (int, error) { + raw, ok := annotations[constants.HelperJWTSVIDFileModeAnnotation] + if !ok || strings.TrimSpace(raw) == "" { + return defaultJWTSVIDFileMode, nil + } + + mode, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0, fmt.Errorf("invalid %s %q: must be an integer (e.g. 600)", constants.HelperJWTSVIDFileModeAnnotation, raw) + } + if mode <= 0 { + return 0, fmt.Errorf("invalid %s %q: must be a positive integer (e.g. 600)", constants.HelperJWTSVIDFileModeAnnotation, raw) + } + return mode, nil +} + +// EnsureCertVolumeMount adds the cert directory volume mount to the container +// if it doesn't already exist +func EnsureCertVolumeMount(container *corev1.Container, certPath string) bool { + volumeMount := corev1.VolumeMount{ + Name: constants.SPIFFEEnableCertVolumeName, + MountPath: certPath, + ReadOnly: true, + } + + // Check if this volume mount already exists + for _, vm := range container.VolumeMounts { + if vm.Name == constants.SPIFFEEnableCertVolumeName && vm.MountPath == certPath { + return false // Already exists + } + } + + container.VolumeMounts = append(container.VolumeMounts, volumeMount) + return true +} diff --git a/internal/helper/jwt_test.go b/internal/helper/jwt_test.go new file mode 100644 index 0000000..7f71e63 --- /dev/null +++ b/internal/helper/jwt_test.go @@ -0,0 +1,122 @@ +package helper + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseJWTConfigFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected []SPIFFEHelperJWTConfig + }{ + { + name: "basic JWT config", + annotations: map[string]string{ + "spiffe.io/helper-jwt-audience": "sts.amazonaws.com", + "spiffe.io/helper-jwt-filename": "tokens/token", + }, + expected: []SPIFFEHelperJWTConfig{ + { + JWTAudience: "sts.amazonaws.com", + JWTExtraAudiences: []string{}, + JWTSVIDFilename: "tokens/token", + }, + }, + }, + { + name: "JWT config with extra audiences", + annotations: map[string]string{ + "spiffe.io/helper-jwt-audience": "sts.amazonaws.com", + "spiffe.io/helper-jwt-filename": "tokens/aws", + "spiffe.io/helper-jwt-extra-audiences": "audience1, audience2", + }, + expected: []SPIFFEHelperJWTConfig{ + { + JWTAudience: "sts.amazonaws.com", + JWTSVIDFilename: "tokens/aws", + JWTExtraAudiences: []string{"audience1", "audience2"}, + }, + }, + }, + { + name: "default filename when not specified", + annotations: map[string]string{ + "spiffe.io/helper-jwt-audience": "sts.amazonaws.com", + }, + expected: []SPIFFEHelperJWTConfig{ + { + JWTAudience: "sts.amazonaws.com", + JWTExtraAudiences: []string{}, + JWTSVIDFilename: "tokens/token", + }, + }, + }, + { + name: "no JWT config when audience missing", + annotations: map[string]string{}, + expected: nil, // Changed from []SPIFFEHelperJWTConfig{} + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseJWTConfigFromAnnotations(tt.annotations) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseJWTSVIDFileModeFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected int + expectErr bool + }{ + { + name: "default when missing", + annotations: map[string]string{}, + expected: 600, + expectErr: false, + }, + { + name: "custom mode", + annotations: map[string]string{ + "spiffe.io/helper-jwt-svid-file-mode": "644", + }, + expected: 644, + expectErr: false, + }, + { + name: "reject non-integer", + annotations: map[string]string{ + "spiffe.io/helper-jwt-svid-file-mode": "abc", + }, + expected: 0, + expectErr: true, + }, + { + name: "reject non-positive", + annotations: map[string]string{ + "spiffe.io/helper-jwt-svid-file-mode": "0", + }, + expected: 0, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mode, err := ParseJWTSVIDFileModeFromAnnotations(tt.annotations) + if tt.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, mode) + }) + } +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 1875d82..e5a3cc5 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -173,11 +173,28 @@ func (a *spiffeEnableWebhook) Handle(ctx context.Context, req admission.Request) incIntermediateBundle = true } + // Parse JWT configuration from annotations + jwtConfigs := helper.ParseJWTConfigFromAnnotations(pod.Annotations) + if len(jwtConfigs) > 0 { + logger.Info("Found JWT SVID configuration", + "audience", jwtConfigs[0].JWTAudience, + "filename", jwtConfigs[0].JWTSVIDFilename) + } + + jwtSVIDFileMode, err := helper.ParseJWTSVIDFileModeFromAnnotations(pod.Annotations) + if err != nil { + // Keep going with helper's default; we don't want to reject pods for a bad annotation. + logger.Error(err, "Invalid JWT SVID file mode annotation; using default") + jwtSVIDFileMode = 600 + } + // Generate the spiffe-helper configuration configParams := helper.SPIFFEHelperConfigParams{ AgentAddress: constants.SPIFFEWLSocketPath, CertPath: constants.SPIFFEEnableCertDirectory, IncludeIntermediateBundle: incIntermediateBundle, + JWTConfigs: jwtConfigs, + JWTSVIDFileMode: jwtSVIDFileMode, } spiffeHelper, err := helper.NewSPIFFEHelper(configParams) @@ -199,15 +216,33 @@ func (a *spiffeEnableWebhook) Handle(ctx context.Context, req admission.Request) pod.Spec.Volumes = append(pod.Spec.Volumes, getCertsVolume()) } - if !workload.InitContainerExists(pod, helper.SPIFFEHelperSidecarContainerName) { - logger.Info("Adding spiffe-helper sidecar container", "initContainerName", helper.SPIFFEHelperSidecarContainerName) - pod.Spec.InitContainers = append([]corev1.Container{spiffeHelper.GetSidecarContainer()}, pod.Spec.InitContainers...) - } - + // Add the init container to write the config if !workload.InitContainerExists(pod, helper.SPIFFEHelperInitContainerName) { logger.Info("Adding init container to inject spiffe-helper config", "initContainerName", helper.SPIFFEHelperInitContainerName) pod.Spec.InitContainers = append([]corev1.Container{spiffeHelper.GetInitContainer()}, pod.Spec.InitContainers...) } + + // Add spiffe-helper as a sidecar container, not an init container + if !workload.ContainerExists(pod.Spec.Containers, helper.SPIFFEHelperSidecarContainerName) { + logger.Info("Adding spiffe-helper sidecar container", "containerName", helper.SPIFFEHelperSidecarContainerName) + pod.Spec.Containers = append(pod.Spec.Containers, spiffeHelper.GetSidecarContainer()) + } + + // ONLY add cert volume mount to application containers if JWT SVIDs are configured + if len(jwtConfigs) > 0 { + for i := range pod.Spec.Containers { + container := &pod.Spec.Containers[i] + // Skip the spiffe-helper sidecar itself + if container.Name == helper.SPIFFEHelperSidecarContainerName { + continue + } + if helper.EnsureCertVolumeMount(container, constants.SPIFFEEnableCertDirectory) { + logger.Info("Added cert volume mount for JWT access", + "containerName", container.Name, + "mountPath", constants.SPIFFEEnableCertDirectory) + } + } + } } } } diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go index 6140ec2..84fc8cb 100644 --- a/internal/webhook/webhook_test.go +++ b/internal/webhook/webhook_test.go @@ -180,7 +180,7 @@ func TestSpiffeEnableWebhook_Handle(t *testing.T) { // Helper Sidecar Container foundHelperSidecar := false - for _, c := range mutatedPod.Spec.InitContainers { + for _, c := range mutatedPod.Spec.Containers { if c.Name == helper.SPIFFEHelperSidecarContainerName { foundHelperSidecar = true assert.Equal(t, helper.SPIFFEHelperImage, c.Image) @@ -191,8 +191,8 @@ func TestSpiffeEnableWebhook_Handle(t *testing.T) { } assert.True(t, foundHelperSidecar, "SPIFFE Helper sidecar container not found") - assert.Len(t, mutatedPod.Spec.Containers, 1) // app - assert.Len(t, mutatedPod.Spec.InitContainers, 2) // init + helper + assert.Len(t, mutatedPod.Spec.Containers, 2) // app + helper + assert.Len(t, mutatedPod.Spec.InitContainers, 1) // helper-init }, }, { @@ -250,8 +250,8 @@ func TestSpiffeEnableWebhook_Handle(t *testing.T) { expectedAllowed: true, expectedPatched: true, validatePod: func(t *testing.T, mutatedPod *corev1.Pod) { - assert.Len(t, mutatedPod.Spec.Containers, 2) // app + proxy - assert.Len(t, mutatedPod.Spec.InitContainers, 3) // helper-init + helper + proxy-init + assert.Len(t, mutatedPod.Spec.Containers, 3) // app + helper + proxy + assert.Len(t, mutatedPod.Spec.InitContainers, 2) // helper-init + proxy-init }, }, {