From 1ce42d587abc0f107fb37e60bcff73d49002d2ba Mon Sep 17 00:00:00 2001 From: Michael Munch Date: Thu, 20 Aug 2026 20:15:26 +0200 Subject: [PATCH] fix(spire-server): support postgres TLS client-certificate (passwordless) auth (#922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(spire-server): support postgres TLS client-certificate (passwordless) auth The postgres datastore always injected a password into the connection string, always created the -dbpw Secret, and always set the DBPW env var, with no way to use TLS client-certificate (or IAM) authentication. This forced a dummy password (e.g. "unused") when authenticating with certs. - Map dataStore.sql.rootCAPath / clientCertPath / clientKeyPath to the postgres connection-string options sslrootcert / sslcert / sslkey (previously these were mysql-only and rejected for postgres). MySQL keeps using the root_ca_path / client_cert_path / client_key_path plugin fields, now correctly gated to mysql/aws_mysql only. - For postgres/aws_postgres, when dataStore.sql.password is empty, omit "password=${DBPW}" from the connection string and skip creating the -dbpw Secret and the DBPW/RODBPW env vars (mirrors the existing gcp_mysql_sa_iam passwordless behavior). - Add a guard: for postgres, dataStore.sql.password and clientCertPath are mutually exclusive. - Fix a stray tab in the mysql client_key_path config field. - Update value docs and regenerate the README. Existing configurations with a password set are unaffected. Signed-off-by: Michael Munch * 🐛 fix(spire-server): keep postgres password when external secret is used The postgres passwordless path keyed only on an empty password, so enabling dataStore.sql.externalSecret (or readOnly.externalSecret) with an empty password dropped the password token from the connection string and skipped the DBPW/RODBPW env vars, breaking external-secret auth. - Add shared passwordless predicates that also require external secrets to be disabled, evaluated independently for read-write and read-only. - Use the predicates in datastore-config, secret.yaml, and server-resource.yaml so the gating cannot drift. - Add unit tests for postgres with read-write and read-only external secrets plus the cert-auth passwordless case. Signed-off-by: Michael Munch * 🔁 ci: re-trigger checks Re-run CI; the previous spiffe-step-ssh integration job failed on an unrelated flaky SSH host-key verification on k8s v1.35.1 (passed on v1.33.7 and v1.34.3). Signed-off-by: Michael Munch --------- Signed-off-by: Michael Munch Co-authored-by: kfox1111 --- charts/spire/charts/spire-server/README.md | 8 +- .../spire-server/templates/_helpers.tpl | 36 ++++++++- .../spire-server/templates/configmap.yaml | 4 +- .../charts/spire-server/templates/secret.yaml | 3 +- .../templates/server-resource.yaml | 20 +++-- charts/spire/charts/spire-server/values.yaml | 8 +- tests/unit/spire_test.go | 77 +++++++++++++++++++ 7 files changed, 135 insertions(+), 21 deletions(-) diff --git a/charts/spire/charts/spire-server/README.md b/charts/spire/charts/spire-server/README.md index 45f0241..92461b6 100644 --- a/charts/spire/charts/spire-server/README.md +++ b/charts/spire/charts/spire-server/README.md @@ -141,13 +141,13 @@ In order to run Tornjak with simple HTTP Connection only, make sure you don't cr | `dataStore.sql.host` | Only used when type != "sqlite3" | `""` | | `dataStore.sql.port` | If 0 (default), it will auto set to 5432 for postgres and 3306 for mysql. Only used by those databases. | `0` | | `dataStore.sql.username` | Only used when type != "sqlite3" | `spire` | -| `dataStore.sql.password` | Only used when type != "sqlite3" | `""` | +| `dataStore.sql.password` | Only used when type != "sqlite3". For postgres/aws_postgres, leave empty to omit the password from the connection string (e.g. TLS client-certificate or IAM authentication). | `""` | | `dataStore.sql.file` | Data source file. Only used when type == "sqlite3" and inMemory is false | `/run/spire/data/datastore.sqlite3` | | `dataStore.sql.inMemory` | Hold the sqlite3 datastore in memory instead of in a file, in which case `file` is unused. The datastore starts empty on every restart, so this only suits a single replica whose registration entries are recreated at startup, for example by the controller manager writing static entries. Required to run as a deployment on sqlite3, since a deployment has no durable per-pod storage. | `false` | | `dataStore.sql.options` | takes an array of objects of form {: } to use when building the database connection string | `[]` | -| `dataStore.sql.rootCAPath` | Path to Root CA bundle (MySQL only) | `""` | -| `dataStore.sql.clientCertPath` | Path to client certificate (MySQL only) | `""` | -| `dataStore.sql.clientKeyPath` | Path to private key for client certificate (MySQL only) | `""` | +| `dataStore.sql.rootCAPath` | Path to Root CA bundle. Supports MySQL and postgres. | `""` | +| `dataStore.sql.clientCertPath` | Path to client certificate. Supports MySQL and postgres. | `""` | +| `dataStore.sql.clientKeyPath` | Path to private key for client certificate. Supports MySQL and postgres. | `""` | | `dataStore.sql.externalSecret.enabled` | Enable external secret for datastore creds | `false` | | `dataStore.sql.externalSecret.name` | The name of the secret object | `""` | | `dataStore.sql.externalSecret.key` | The key of the secret object whose value is the dataStore.sql password | `""` | diff --git a/charts/spire/charts/spire-server/templates/_helpers.tpl b/charts/spire/charts/spire-server/templates/_helpers.tpl index 80c53f4..5dc537d 100644 --- a/charts/spire/charts/spire-server/templates/_helpers.tpl +++ b/charts/spire/charts/spire-server/templates/_helpers.tpl @@ -301,6 +301,20 @@ current-context: cluster {{- end }} {{- end }} +{{- define "spire-server.datastore-is-postgres" -}} +{{- or (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres") -}} +{{- end }} + +{{- define "spire-server.datastore-postgres-passwordless" -}} +{{- $isPostgres := eq (include "spire-server.datastore-is-postgres" .) "true" -}} +{{- and $isPostgres (eq .Values.dataStore.sql.password "") (not .Values.dataStore.sql.externalSecret.enabled) -}} +{{- end }} + +{{- define "spire-server.datastore-postgres-ro-passwordless" -}} +{{- $isPostgres := eq (include "spire-server.datastore-is-postgres" .) "true" -}} +{{- and $isPostgres (eq .Values.dataStore.sql.readOnly.password "") (not .Values.dataStore.sql.readOnly.externalSecret.enabled) -}} +{{- end }} + {{- define "spire-server.datastore-config" }} {{- $config := dict }} {{- $pw := "" }} @@ -349,18 +363,32 @@ current-context: cluster {{- else if or (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres") }} {{- if eq .Values.dataStore.sql.databaseType "postgres" }} {{- $_ := set $config "database_type" "postgres" }} - {{- $pw = " password=${DBPW}" }} - {{- $ropw = " password=${RODBPW}" }} {{- else }} {{- $_ := set $config "database_type" (list (dict "aws_postgres" (dict "region" .Values.dataStore.sql.region))) }} {{- end }} + {{- if ne (include "spire-server.datastore-postgres-passwordless" .) "true" }} + {{- $pw = " password=${DBPW}" }} + {{- end }} + {{- if ne (include "spire-server.datastore-postgres-ro-passwordless" .) "true" }} + {{- $ropw = " password=${RODBPW}" }} + {{- end }} + {{- $sslPaths := "" }} + {{- if ne .Values.dataStore.sql.rootCAPath "" }} + {{- $sslPaths = printf "%s sslrootcert=%s" $sslPaths .Values.dataStore.sql.rootCAPath }} + {{- end }} + {{- if ne .Values.dataStore.sql.clientCertPath "" }} + {{- $sslPaths = printf "%s sslcert=%s" $sslPaths .Values.dataStore.sql.clientCertPath }} + {{- end }} + {{- if ne .Values.dataStore.sql.clientKeyPath "" }} + {{- $sslPaths = printf "%s sslkey=%s" $sslPaths .Values.dataStore.sql.clientKeyPath }} + {{- end }} {{- $port := int .Values.dataStore.sql.port | default 5432 }} {{- $options:= include "spire-server.config-postgresql-options" .Values.dataStore.sql.options }} - {{- $_ := set $config "connection_string" (printf "dbname=%s user=%s%s host=%s port=%d%s" .Values.dataStore.sql.databaseName .Values.dataStore.sql.username $pw .Values.dataStore.sql.host $port $options) }} + {{- $_ := set $config "connection_string" (printf "dbname=%s user=%s%s host=%s port=%d%s%s" .Values.dataStore.sql.databaseName .Values.dataStore.sql.username $pw .Values.dataStore.sql.host $port $options $sslPaths) }} {{- if .Values.dataStore.sql.readOnly.enabled }} {{- $roPort := int .Values.dataStore.sql.readOnly.port | default 5432 }} {{- $roOptions:= include "spire-server.config-postgresql-options" .Values.dataStore.sql.readOnly.options }} - {{- $_ := set $config "ro_connection_string" (printf "dbname=%s user=%s%s host=%s port=%d%s" .Values.dataStore.sql.readOnly.databaseName .Values.dataStore.sql.readOnly.username $ropw .Values.dataStore.sql.readOnly.host $roPort $roOptions) }} + {{- $_ := set $config "ro_connection_string" (printf "dbname=%s user=%s%s host=%s port=%d%s%s" .Values.dataStore.sql.readOnly.databaseName .Values.dataStore.sql.readOnly.username $ropw .Values.dataStore.sql.readOnly.host $roPort $roOptions $sslPaths) }} {{- end }} {{- else }} {{- fail "Unsupported database type" }} diff --git a/charts/spire/charts/spire-server/templates/configmap.yaml b/charts/spire/charts/spire-server/templates/configmap.yaml index befedbc..2c4211b 100644 --- a/charts/spire/charts/spire-server/templates/configmap.yaml +++ b/charts/spire/charts/spire-server/templates/configmap.yaml @@ -173,6 +173,7 @@ plugins: sql: plugin_data: {{ include "spire-server.datastore-config" . | nindent 8 }} + {{- if or (eq .Values.dataStore.sql.databaseType "mysql") (eq .Values.dataStore.sql.databaseType "aws_mysql") }} {{- if ne .Values.dataStore.sql.rootCAPath "" }} root_ca_path: {{ .Values.dataStore.sql.rootCAPath }} {{- end }} @@ -180,7 +181,8 @@ plugins: client_cert_path: {{ .Values.dataStore.sql.clientCertPath }} {{- end }} {{- if ne .Values.dataStore.sql.clientKeyPath "" }} - client_key_path : {{ .Values.dataStore.sql.clientKeyPath }} + client_key_path: {{ .Values.dataStore.sql.clientKeyPath }} + {{- end }} {{- end }} max_open_conns: {{ .Values.dataStore.sql.maxOpenConns }} max_idle_conns: {{ .Values.dataStore.sql.maxIdleConns }} diff --git a/charts/spire/charts/spire-server/templates/secret.yaml b/charts/spire/charts/spire-server/templates/secret.yaml index b0baa59..ae5cfc7 100644 --- a/charts/spire/charts/spire-server/templates/secret.yaml +++ b/charts/spire/charts/spire-server/templates/secret.yaml @@ -7,7 +7,8 @@ {{- if and (.Values.dataStore.sql.externalSecret.enabled) (eq .Values.dataStore.sql.externalSecret.key "") }} {{- fail "dataStore.sql.externalSecret.key cannot be empty string when dataStore.sql.externalSecret is enabled" }} {{- end }} -{{- if and (ne .Values.dataStore.sql.databaseType "sqlite3") (not .Values.dataStore.sql.externalSecret.enabled) (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") }} +{{- $postgresPasswordless := eq (include "spire-server.datastore-postgres-passwordless" .) "true" }} +{{- if and (ne .Values.dataStore.sql.databaseType "sqlite3") (not .Values.dataStore.sql.externalSecret.enabled) (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") (not $postgresPasswordless) }} apiVersion: v1 kind: Secret metadata: diff --git a/charts/spire/charts/spire-server/templates/server-resource.yaml b/charts/spire/charts/spire-server/templates/server-resource.yaml index ea6a428..009fc84 100644 --- a/charts/spire/charts/spire-server/templates/server-resource.yaml +++ b/charts/spire/charts/spire-server/templates/server-resource.yaml @@ -59,17 +59,21 @@ {{- if hasKey .Values.dataStore.sql "plugin_data" }} {{- fail "The plugin_data setting to the sql data store is no longer supported." }} {{- end }} -{{- if and (ne .Values.dataStore.sql.databaseType "mysql") (ne .Values.dataStore.sql.databaseType "aws_mysql") }} +{{- $certPathDatabaseType := or (eq .Values.dataStore.sql.databaseType "mysql") (eq .Values.dataStore.sql.databaseType "aws_mysql") (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres") }} +{{- if not $certPathDatabaseType }} {{- if ne .Values.dataStore.sql.rootCAPath "" }} -{{- fail "rootCAPath can only be set with database type mysql or aws_mysql." }} +{{- fail "rootCAPath can only be set with database type mysql, aws_mysql, postgres or aws_postgres." }} {{- end }} {{- if ne .Values.dataStore.sql.clientCertPath "" }} -{{- fail "clientCertPath can only be set with database type mysql or aws_mysql." }} +{{- fail "clientCertPath can only be set with database type mysql, aws_mysql, postgres or aws_postgres." }} {{- end }} {{- if ne .Values.dataStore.sql.clientKeyPath "" }} -{{- fail "clientKeyPath can only be set with database type mysql or aws_mysql." }} +{{- fail "clientKeyPath can only be set with database type mysql, aws_mysql, postgres or aws_postgres." }} {{- end }} {{- end }} +{{- if and (or (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres")) (ne .Values.dataStore.sql.password "") (ne .Values.dataStore.sql.clientCertPath "") }} +{{- fail "dataStore.sql.password and dataStore.sql.clientCertPath are mutually exclusive for postgres; use one authentication method." }} +{{- end }} {{- $pluginsToLoad := include "spire-lib.extract_custom_plugin_images" . | fromYamlArray }} {{- $jwtExecNeeded := false }} {{- range $name, $value := .Values.kubeConfigs }} @@ -316,7 +320,9 @@ spec: {{- with .Values.extraEnv }} {{- . | toYaml | nindent 10 }} {{- end }} - {{- if and (ne .Values.dataStore.sql.databaseType "sqlite3") (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") }} + {{- $postgresPasswordless := eq (include "spire-server.datastore-postgres-passwordless" .) "true" }} + {{- $postgresRoPasswordless := eq (include "spire-server.datastore-postgres-ro-passwordless" .) "true" }} + {{- if and (ne .Values.dataStore.sql.databaseType "sqlite3") (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") (not $postgresPasswordless) }} {{- if .Values.dataStore.sql.externalSecret.enabled }} - name: DBPW valueFrom: @@ -330,7 +336,8 @@ spec: name: {{ $fullname }}-dbpw key: DBPW {{- end }} - {{- if and .Values.dataStore.sql.readOnly.enabled (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") }} + {{- end }} + {{- if and .Values.dataStore.sql.readOnly.enabled (ne .Values.dataStore.sql.databaseType "gcp_mysql_sa_iam") (not $postgresRoPasswordless) }} {{- if .Values.dataStore.sql.readOnly.externalSecret.enabled }} - name: RODBPW valueFrom: @@ -345,7 +352,6 @@ spec: key: RODBPW {{- end }} {{- end }} - {{- end }} {{- if ne .Values.keyManager.awsKMS.accessKeyID "" }} - name: AWS_KMS_ACCESS_KEY_ID valueFrom: diff --git a/charts/spire/charts/spire-server/values.yaml b/charts/spire/charts/spire-server/values.yaml index dd5e7cc..191623e 100644 --- a/charts/spire/charts/spire-server/values.yaml +++ b/charts/spire/charts/spire-server/values.yaml @@ -194,7 +194,7 @@ dataStore: port: 0 ## @param dataStore.sql.username Only used when type != "sqlite3" username: spire - ## @param dataStore.sql.password Only used when type != "sqlite3" + ## @param dataStore.sql.password Only used when type != "sqlite3". For postgres/aws_postgres, leave empty to omit the password from the connection string (e.g. TLS client-certificate or IAM authentication). password: "" ## @param dataStore.sql.file Data source file. Only used when type == "sqlite3" and inMemory is false file: "/run/spire/data/datastore.sqlite3" @@ -203,11 +203,11 @@ dataStore: ## @param dataStore.sql.options [array] takes an array of objects of form {: } to use when building the database connection string options: [] - ## @param dataStore.sql.rootCAPath Path to Root CA bundle (MySQL only) + ## @param dataStore.sql.rootCAPath Path to Root CA bundle. Supports MySQL and postgres. rootCAPath: "" - ## @param dataStore.sql.clientCertPath Path to client certificate (MySQL only) + ## @param dataStore.sql.clientCertPath Path to client certificate. Supports MySQL and postgres. clientCertPath: "" - ## @param dataStore.sql.clientKeyPath Path to private key for client certificate (MySQL only) + ## @param dataStore.sql.clientKeyPath Path to private key for client certificate. Supports MySQL and postgres. clientKeyPath: "" ## When an external source creates the secret. The secret should reside in the same namespace as the spire server diff --git a/tests/unit/spire_test.go b/tests/unit/spire_test.go index e446a24..e76c249 100644 --- a/tests/unit/spire_test.go +++ b/tests/unit/spire_test.go @@ -663,4 +663,81 @@ spire-server: Expect(err).Should(Succeed()) }) }) + Describe("spire-server.dataStore.sql.postgres passwordless", func() { + It("omits password and the -dbpw Secret for cert auth with an empty password", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + dataStore: + sql: + databaseType: postgres + host: db.example.org + username: spire + password: "" + rootCAPath: /run/spire/db-ca/ca.crt + clientCertPath: /run/spire/db-certs/tls.crt + clientKeyPath: /run/spire/db-certs/tls.key +`) + Expect(err).Should(Succeed()) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + ShouldNot(ContainSubstring("password=${DBPW}")) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + Should(ContainSubstring("sslrootcert=/run/spire/db-ca/ca.crt")) + Expect(objs["spire/charts/spire-server/templates/secret.yaml"]). + ShouldNot(ContainSubstring("kind: Secret")) + Expect(objs["spire/charts/spire-server/templates/server-resource.yaml"]). + ShouldNot(ContainSubstring("name: DBPW")) + }) + + It("keeps the password token and DBPW env when an external secret provides the password", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + dataStore: + sql: + databaseType: postgres + host: db.example.org + username: spire + password: "" + externalSecret: + enabled: true + name: my-db-secret + key: password +`) + Expect(err).Should(Succeed()) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + Should(ContainSubstring("password=${DBPW}")) + serverResource := objs["spire/charts/spire-server/templates/server-resource.yaml"] + Expect(serverResource).Should(ContainSubstring("name: DBPW")) + Expect(serverResource).Should(ContainSubstring("name: my-db-secret")) + }) + + It("keeps the RODBPW env when a read-only external secret provides the password", func() { + objs, err := ValueStringRender(chart, ` +spire-server: + dataStore: + sql: + databaseType: postgres + host: db.example.org + username: spire + password: "" + rootCAPath: /run/spire/db-ca/ca.crt + clientCertPath: /run/spire/db-certs/tls.crt + clientKeyPath: /run/spire/db-certs/tls.key + readOnly: + enabled: true + host: ro.example.org + username: spire + password: "" + externalSecret: + enabled: true + name: my-ro-db-secret + key: password +`) + Expect(err).Should(Succeed()) + Expect(objs["spire/charts/spire-server/templates/configmap.yaml"]). + Should(ContainSubstring("password=${RODBPW}")) + serverResource := objs["spire/charts/spire-server/templates/server-resource.yaml"] + Expect(serverResource).Should(ContainSubstring("name: RODBPW")) + Expect(serverResource).Should(ContainSubstring("name: my-ro-db-secret")) + }) + }) })