fix(spire-server): support postgres TLS client-certificate (passwordless) auth (#922)

* 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 <[email protected]>

* 🐛 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 <[email protected]>

* 🔁 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 <[email protected]>

---------

Signed-off-by: Michael Munch <[email protected]>
Co-authored-by: kfox1111 <[email protected]>
This commit is contained in:
Michael Munch
2026-08-20 11:15:26 -07:00
committed by GitHub
co-authored by kfox1111
parent ab5e5d8677
commit 1ce42d587a
7 changed files with 135 additions and 21 deletions
+4 -4
View File
@@ -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.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.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.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.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.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 {<key>: <value>} to use when building the database connection string | `[]` | | `dataStore.sql.options` | takes an array of objects of form {<key>: <value>} to use when building the database connection string | `[]` |
| `dataStore.sql.rootCAPath` | Path to Root CA bundle (MySQL only) | `""` | | `dataStore.sql.rootCAPath` | Path to Root CA bundle. Supports MySQL and postgres. | `""` |
| `dataStore.sql.clientCertPath` | Path to client certificate (MySQL only) | `""` | | `dataStore.sql.clientCertPath` | Path to client certificate. Supports MySQL and postgres. | `""` |
| `dataStore.sql.clientKeyPath` | Path to private key for client certificate (MySQL only) | `""` | | `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.enabled` | Enable external secret for datastore creds | `false` |
| `dataStore.sql.externalSecret.name` | The name of the secret object | `""` | | `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 | `""` | | `dataStore.sql.externalSecret.key` | The key of the secret object whose value is the dataStore.sql password | `""` |
@@ -301,6 +301,20 @@ current-context: cluster
{{- end }} {{- end }}
{{- 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" }} {{- define "spire-server.datastore-config" }}
{{- $config := dict }} {{- $config := dict }}
{{- $pw := "" }} {{- $pw := "" }}
@@ -349,18 +363,32 @@ current-context: cluster
{{- else if or (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres") }} {{- else if or (eq .Values.dataStore.sql.databaseType "postgres") (eq .Values.dataStore.sql.databaseType "aws_postgres") }}
{{- if eq .Values.dataStore.sql.databaseType "postgres" }} {{- if eq .Values.dataStore.sql.databaseType "postgres" }}
{{- $_ := set $config "database_type" "postgres" }} {{- $_ := set $config "database_type" "postgres" }}
{{- $pw = " password=${DBPW}" }}
{{- $ropw = " password=${RODBPW}" }}
{{- else }} {{- else }}
{{- $_ := set $config "database_type" (list (dict "aws_postgres" (dict "region" .Values.dataStore.sql.region))) }} {{- $_ := set $config "database_type" (list (dict "aws_postgres" (dict "region" .Values.dataStore.sql.region))) }}
{{- end }} {{- 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 }} {{- $port := int .Values.dataStore.sql.port | default 5432 }}
{{- $options:= include "spire-server.config-postgresql-options" .Values.dataStore.sql.options }} {{- $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 }} {{- if .Values.dataStore.sql.readOnly.enabled }}
{{- $roPort := int .Values.dataStore.sql.readOnly.port | default 5432 }} {{- $roPort := int .Values.dataStore.sql.readOnly.port | default 5432 }}
{{- $roOptions:= include "spire-server.config-postgresql-options" .Values.dataStore.sql.readOnly.options }} {{- $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 }} {{- end }}
{{- else }} {{- else }}
{{- fail "Unsupported database type" }} {{- fail "Unsupported database type" }}
@@ -173,6 +173,7 @@ plugins:
sql: sql:
plugin_data: plugin_data:
{{ include "spire-server.datastore-config" . | nindent 8 }} {{ 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 "" }} {{- if ne .Values.dataStore.sql.rootCAPath "" }}
root_ca_path: {{ .Values.dataStore.sql.rootCAPath }} root_ca_path: {{ .Values.dataStore.sql.rootCAPath }}
{{- end }} {{- end }}
@@ -180,7 +181,8 @@ plugins:
client_cert_path: {{ .Values.dataStore.sql.clientCertPath }} client_cert_path: {{ .Values.dataStore.sql.clientCertPath }}
{{- end }} {{- end }}
{{- if ne .Values.dataStore.sql.clientKeyPath "" }} {{- if ne .Values.dataStore.sql.clientKeyPath "" }}
client_key_path : {{ .Values.dataStore.sql.clientKeyPath }} client_key_path: {{ .Values.dataStore.sql.clientKeyPath }}
{{- end }}
{{- end }} {{- end }}
max_open_conns: {{ .Values.dataStore.sql.maxOpenConns }} max_open_conns: {{ .Values.dataStore.sql.maxOpenConns }}
max_idle_conns: {{ .Values.dataStore.sql.maxIdleConns }} max_idle_conns: {{ .Values.dataStore.sql.maxIdleConns }}
@@ -7,7 +7,8 @@
{{- if and (.Values.dataStore.sql.externalSecret.enabled) (eq .Values.dataStore.sql.externalSecret.key "") }} {{- 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" }} {{- fail "dataStore.sql.externalSecret.key cannot be empty string when dataStore.sql.externalSecret is enabled" }}
{{- end }} {{- 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 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -59,17 +59,21 @@
{{- if hasKey .Values.dataStore.sql "plugin_data" }} {{- if hasKey .Values.dataStore.sql "plugin_data" }}
{{- fail "The plugin_data setting to the sql data store is no longer supported." }} {{- fail "The plugin_data setting to the sql data store is no longer supported." }}
{{- end }} {{- 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 "" }} {{- 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 }} {{- end }}
{{- if ne .Values.dataStore.sql.clientCertPath "" }} {{- 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 }} {{- end }}
{{- if ne .Values.dataStore.sql.clientKeyPath "" }} {{- 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 }}
{{- 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 }} {{- $pluginsToLoad := include "spire-lib.extract_custom_plugin_images" . | fromYamlArray }}
{{- $jwtExecNeeded := false }} {{- $jwtExecNeeded := false }}
{{- range $name, $value := .Values.kubeConfigs }} {{- range $name, $value := .Values.kubeConfigs }}
@@ -316,7 +320,9 @@ spec:
{{- with .Values.extraEnv }} {{- with .Values.extraEnv }}
{{- . | toYaml | nindent 10 }} {{- . | toYaml | nindent 10 }}
{{- end }} {{- 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 }} {{- if .Values.dataStore.sql.externalSecret.enabled }}
- name: DBPW - name: DBPW
valueFrom: valueFrom:
@@ -330,7 +336,8 @@ spec:
name: {{ $fullname }}-dbpw name: {{ $fullname }}-dbpw
key: DBPW key: DBPW
{{- end }} {{- 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 }} {{- if .Values.dataStore.sql.readOnly.externalSecret.enabled }}
- name: RODBPW - name: RODBPW
valueFrom: valueFrom:
@@ -345,7 +352,6 @@ spec:
key: RODBPW key: RODBPW
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }}
{{- if ne .Values.keyManager.awsKMS.accessKeyID "" }} {{- if ne .Values.keyManager.awsKMS.accessKeyID "" }}
- name: AWS_KMS_ACCESS_KEY_ID - name: AWS_KMS_ACCESS_KEY_ID
valueFrom: valueFrom:
+4 -4
View File
@@ -194,7 +194,7 @@ dataStore:
port: 0 port: 0
## @param dataStore.sql.username Only used when type != "sqlite3" ## @param dataStore.sql.username Only used when type != "sqlite3"
username: spire 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: "" password: ""
## @param dataStore.sql.file Data source file. Only used when type == "sqlite3" and inMemory is false ## @param dataStore.sql.file Data source file. Only used when type == "sqlite3" and inMemory is false
file: "/run/spire/data/datastore.sqlite3" file: "/run/spire/data/datastore.sqlite3"
@@ -203,11 +203,11 @@ dataStore:
## @param dataStore.sql.options [array] takes an array of objects of form {<key>: <value>} to use when building the database connection string ## @param dataStore.sql.options [array] takes an array of objects of form {<key>: <value>} to use when building the database connection string
options: [] 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: "" rootCAPath: ""
## @param dataStore.sql.clientCertPath Path to client certificate (MySQL only) ## @param dataStore.sql.clientCertPath Path to client certificate. Supports MySQL and postgres.
clientCertPath: "" 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: "" clientKeyPath: ""
## When an external source creates the secret. The secret should reside in the same namespace as the spire server ## When an external source creates the secret. The secret should reside in the same namespace as the spire server
+77
View File
@@ -663,4 +663,81 @@ spire-server:
Expect(err).Should(Succeed()) 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"))
})
})
}) })