diff --git a/apis/cluster/mysql/v1alpha1/provider_types.go b/apis/cluster/mysql/v1alpha1/provider_types.go index a6dc93fa..2846c714 100644 --- a/apis/cluster/mysql/v1alpha1/provider_types.go +++ b/apis/cluster/mysql/v1alpha1/provider_types.go @@ -42,6 +42,8 @@ type ProviderConfigSpec struct { } // TLSConfig defines the TLS configuration for the provider when tls=custom. +// CACert verifies the server. ClientCert and ClientKey are optional and must +// be supplied together when the server requires mutual TLS. type TLSConfig struct { CACert TLSSecret `json:"caCert,omitempty"` ClientCert TLSSecret `json:"clientCert,omitempty"` @@ -59,12 +61,17 @@ const ( // should acquire credentials from a connection secret written by a managed // resource that represents a MySQL server. CredentialsSourceMySQLConnectionSecret xpv2.CredentialsSource = "MySQLConnectionSecret" + + // CredentialsSourceAWSIAMAuth indicates that the provider should + // authenticate to an AWS RDS/Aurora instance using an IAM authentication + // token generated at connection time, instead of a static password. + CredentialsSourceAWSIAMAuth xpv2.CredentialsSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source xpv2.CredentialsSource `json:"source"` // A CredentialsSecretRef is a reference to a MySQL connection secret @@ -77,6 +84,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go index d7b2096f..a4dc4747 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -519,6 +519,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. diff --git a/apis/cluster/postgresql/v1alpha1/provider_types.go b/apis/cluster/postgresql/v1alpha1/provider_types.go index e6c993d9..7807d6e5 100644 --- a/apis/cluster/postgresql/v1alpha1/provider_types.go +++ b/apis/cluster/postgresql/v1alpha1/provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ProviderConfigSpec defines the desired state of a ProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` @@ -36,6 +37,23 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig selects the CA bundle for PostgreSQL server verification. +type TLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert TLSSecret `json:"caCert"` +} + +// TLSSecret selects a Secret key containing a PEM certificate bundle. +type TLSSecret struct { + // SecretRef selects the CA bundle. Namespace, name and key are required. + SecretRef xpv2.SecretKeySelector `json:"secretRef"` } const ( @@ -43,12 +61,17 @@ const ( // should acquire credentials from a connection secret written by a managed // resource that represents a PostgreSQL server. CredentialsSourcePostgreSQLConnectionSecret xpv2.CredentialsSource = "PostgreSQLConnectionSecret" + + // CredentialsSourceAWSIAMAuth indicates that the provider should + // authenticate to an AWS RDS/Aurora instance using an IAM authentication + // token generated at connection time, instead of a static password. + CredentialsSourceAWSIAMAuth xpv2.CredentialsSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret + // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret;AWSIAMAuth Source xpv2.CredentialsSource `json:"source"` // A CredentialsSecretRef is a reference to a PostgreSQL connection secret @@ -61,6 +84,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go index d61f22aa..8dd4dd72 100644 --- a/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -780,6 +780,11 @@ func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. @@ -879,6 +884,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. @@ -1302,3 +1312,35 @@ func (in *SecretKeyMapping) DeepCopy() *SecretKeyMapping { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecret) DeepCopyInto(out *TLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecret. +func (in *TLSSecret) DeepCopy() *TLSSecret { + if in == nil { + return nil + } + out := new(TLSSecret) + in.DeepCopyInto(out) + return out +} diff --git a/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go b/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go index 5e1f1e2e..1b767c3c 100644 --- a/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go @@ -45,7 +45,7 @@ type ClusterProviderConfigSpec struct { // ClusterProviderCredentials required to authenticate. type ClusterProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source MySQLConnectionSecretSource `json:"source"` // A CredentialsSecretRef is a reference to a MySQL connection secret @@ -58,6 +58,12 @@ type ClusterProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // A ClusterProviderConfigStatus reflects the observed state of a ClusterProviderConfig. diff --git a/apis/namespaced/mysql/v1alpha1/provider_types.go b/apis/namespaced/mysql/v1alpha1/provider_types.go index 25abc869..6170ec94 100644 --- a/apis/namespaced/mysql/v1alpha1/provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/provider_types.go @@ -42,6 +42,8 @@ type ProviderConfigSpec struct { } // TLSConfig defines the TLS configuration for the provider when tls=custom. +// CACert verifies the server. ClientCert and ClientKey are optional and must +// be supplied together when the server requires mutual TLS. type TLSConfig struct { CACert TLSSecret `json:"caCert,omitempty"` ClientCert TLSSecret `json:"clientCert,omitempty"` @@ -61,12 +63,17 @@ const ( // should acquire credentials from a connection secret written by a managed // resource that represents a MySQL server. CredentialsSourceMySQLConnectionSecret MySQLConnectionSecretSource = "MySQLConnectionSecret" + + // CredentialsSourceAWSIAMAuth indicates that the provider should + // authenticate to an AWS RDS/Aurora instance using an IAM authentication + // token generated at connection time, instead of a static password. + CredentialsSourceAWSIAMAuth MySQLConnectionSecretSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source MySQLConnectionSecretSource `json:"source"` // A CredentialsSecretRef is a reference to a MySQL connection secret @@ -79,6 +86,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index 1eb25e8c..78dbdfe0 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -143,6 +143,11 @@ func (in *ClusterProviderCredentials) DeepCopyInto(out *ClusterProviderCredentia *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderCredentials. @@ -637,6 +642,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. diff --git a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go index 602b97ba..0ab6d7c5 100644 --- a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ClusterProviderConfigSpec defines the desired state of a ClusterProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ClusterProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ClusterProviderCredentials `json:"credentials"` @@ -36,12 +37,29 @@ type ClusterProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *ClusterTLSConfig `json:"tlsConfig,omitempty"` +} + +// ClusterTLSConfig selects the CA bundle for a ClusterProviderConfig. +type ClusterTLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert ClusterTLSSecret `json:"caCert"` +} + +// ClusterTLSSecret selects a Secret key for a cluster-scoped configuration. +type ClusterTLSSecret struct { + // SecretRef selects the CA bundle. Namespace, name and key are required. + SecretRef xpv2.SecretKeySelector `json:"secretRef"` } // ClusterProviderCredentials required to authenticate. type ClusterProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret + // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret;AWSIAMAuth Source PostgreSQLConnectionSource `json:"source"` // A CredentialsSecretRef is a reference to a PostgreSQL connection secret @@ -54,6 +72,12 @@ type ClusterProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // A ClusterProviderConfigStatus reflects the observed state of a ClusterProviderConfig. diff --git a/apis/namespaced/postgresql/v1alpha1/provider_types.go b/apis/namespaced/postgresql/v1alpha1/provider_types.go index 3bdbfb60..cb6d63f3 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ProviderConfigSpec defines the desired state of a ProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` @@ -36,6 +37,23 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig selects the CA bundle for PostgreSQL server verification. +type TLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert TLSSecret `json:"caCert"` +} + +// TLSSecret selects a Secret key containing a PEM certificate bundle. +type TLSSecret struct { + // SecretRef selects the CA bundle. The Secret must be in the ProviderConfig namespace. + SecretRef xpv2.LocalSecretKeySelector `json:"secretRef"` } type PostgreSQLConnectionSource string @@ -45,12 +63,17 @@ const ( // should acquire credentials from a connection secret written by a managed // resource that represents a PostgreSQL server. CredentialsSourcePostgreSQLConnectionSecret PostgreSQLConnectionSource = "PostgreSQLConnectionSecret" + + // CredentialsSourceAWSIAMAuth indicates that the provider should + // authenticate to an AWS RDS/Aurora instance using an IAM authentication + // token generated at connection time, instead of a static password. + CredentialsSourceAWSIAMAuth PostgreSQLConnectionSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret + // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret;AWSIAMAuth Source PostgreSQLConnectionSource `json:"source"` // A CredentialsSecretRef is a reference to a PostgreSQL connection secret @@ -63,6 +86,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go index 403a0120..cbd7d035 100644 --- a/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,11 @@ func (in *ClusterProviderConfigSpec) DeepCopyInto(out *ClusterProviderConfigSpec *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(ClusterTLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderConfigSpec. @@ -118,6 +123,11 @@ func (in *ClusterProviderCredentials) DeepCopyInto(out *ClusterProviderCredentia *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderCredentials. @@ -130,6 +140,38 @@ func (in *ClusterProviderCredentials) DeepCopy() *ClusterProviderCredentials { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterTLSConfig) DeepCopyInto(out *ClusterTLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTLSConfig. +func (in *ClusterTLSConfig) DeepCopy() *ClusterTLSConfig { + if in == nil { + return nil + } + out := new(ClusterTLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterTLSSecret) DeepCopyInto(out *ClusterTLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTLSSecret. +func (in *ClusterTLSSecret) DeepCopy() *ClusterTLSSecret { + if in == nil { + return nil + } + out := new(ClusterTLSSecret) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -897,6 +939,11 @@ func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. @@ -992,6 +1039,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. @@ -1415,3 +1467,35 @@ func (in *SecretKeyMapping) DeepCopy() *SecretKeyMapping { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecret) DeepCopyInto(out *TLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecret. +func (in *TLSSecret) DeepCopy() *TLSSecret { + if in == nil { + return nil + } + out := new(TLSSecret) + in.DeepCopyInto(out) + return out +} diff --git a/cluster/local/integration_tests.sh b/cluster/local/integration_tests.sh index 903eb896..7b97f0aa 100755 --- a/cluster/local/integration_tests.sh +++ b/cluster/local/integration_tests.sh @@ -51,15 +51,26 @@ MARIADB_ROOT_PW=$(openssl rand -base64 32) MARIADB_TEST_PW=$(openssl rand -base64 32) MSSQL_SA_PW="$(openssl rand -base64 16)Aa1!" # MSSQL requires complex password +# Remove generated private keys even when retaining the test cluster for debugging. +POSTGRES_TLS_DIR="" +cleanup_postgres_tls_files() { + if [[ -n "${POSTGRES_TLS_DIR}" ]]; then + rm -rf "${POSTGRES_TLS_DIR}" + fi +} + # cleanup on exit if [ "$skipcleanup" != true ]; then function cleanup { echo_step "Cleaning up..." + cleanup_postgres_tls_files export KUBECONFIG= cleanup_cluster } trap cleanup EXIT +else + trap cleanup_postgres_tls_files EXIT fi # Global variable to control API type @@ -456,6 +467,8 @@ TLS=false API_TYPE="cluster" run_test integration_tests_mariadb TLS=false API_TYPE="cluster" run_test integration_tests_postgres TLS=false API_TYPE="namespaced" run_test integration_tests_postgres +TLS=true API_TYPE="cluster" run_test integration_tests_postgres +TLS=true API_TYPE="namespaced" run_test integration_tests_postgres # no TLS=false variant - MSSQL uses built-in encryption TLS=true API_TYPE="cluster" run_test integration_tests_mssql diff --git a/cluster/local/postgres.server.yaml b/cluster/local/postgres.server.yaml index bbafcff8..f51f8e8b 100644 --- a/cluster/local/postgres.server.yaml +++ b/cluster/local/postgres.server.yaml @@ -44,6 +44,8 @@ spec: exec: command: - pg_isready + - -h + - 127.0.0.1 - -U - postgres initialDelaySeconds: 5 diff --git a/cluster/local/postgres.tls.server.yaml b/cluster/local/postgres.tls.server.yaml new file mode 100644 index 00000000..c7803ca6 --- /dev/null +++ b/cluster/local/postgres.tls.server.yaml @@ -0,0 +1,72 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgresdb-postgresql + namespace: default +spec: + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgresdb-postgresql +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgresdb-postgresql + namespace: default +spec: + serviceName: postgresdb-postgresql + replicas: 1 + selector: + matchLabels: + app: postgresdb-postgresql + template: + metadata: + labels: + app: postgresdb-postgresql + spec: + containers: + - name: postgresql + image: postgres:${POSTGRES_VERSION} + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgresdb-creds + key: password + ports: + - containerPort: 5432 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql + - name: server-tls + mountPath: /etc/postgresql/tls + readOnly: true + readinessProbe: + exec: + command: + - pg_isready + - -h + - 127.0.0.1 + - -U + - postgres + initialDelaySeconds: 5 + periodSeconds: 5 + args: + - postgres + - -c + - ssl=on + - -c + - ssl_cert_file=/etc/postgresql/tls/server-cert.pem + - -c + - ssl_key_file=/etc/postgresql/tls/server-key.pem + volumes: + - name: data + emptyDir: {} + - name: server-tls + secret: + secretName: postgres-server-tls + defaultMode: 288 + securityContext: + fsGroup: 999 diff --git a/cluster/local/postgresdb_functions.sh b/cluster/local/postgresdb_functions.sh index 48622c1b..a5e0c14a 100644 --- a/cluster/local/postgresdb_functions.sh +++ b/cluster/local/postgresdb_functions.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -setup_postgresdb_no_tls() { +setup_postgresdb() { echo_step "Installing PostgresDB into default namespace" postgres_root_pw=$(LC_ALL=C tr -cd "A-Za-z0-9" "${POSTGRES_TLS_DIR}/ca.cnf" < /dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -subj "/CN=postgresdb-postgresql.default.svc.cluster.local" \ + -keyout "${POSTGRES_TLS_DIR}/server-key.pem" -out "${POSTGRES_TLS_DIR}/server.csr" > /dev/null 2>&1 + echo "subjectAltName=DNS:postgresdb-postgresql.default.svc.cluster.local" > "${POSTGRES_TLS_DIR}/server.ext" + openssl x509 -req -days 1 -in "${POSTGRES_TLS_DIR}/server.csr" \ + -CA "${POSTGRES_TLS_DIR}/ca-cert.pem" -CAkey "${POSTGRES_TLS_DIR}/ca-key.pem" -CAcreateserial \ + -extfile "${POSTGRES_TLS_DIR}/server.ext" -out "${POSTGRES_TLS_DIR}/server-cert.pem" > /dev/null 2>&1 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=untrusted-e2e-ca" \ + -keyout "${POSTGRES_TLS_DIR}/wrong-key.pem" -out "${POSTGRES_TLS_DIR}/wrong-ca.pem" > /dev/null 2>&1 + "${KUBECTL}" create secret generic postgres-server-tls \ + --from-file=server-cert.pem="${POSTGRES_TLS_DIR}/server-cert.pem" \ + --from-file=server-key.pem="${POSTGRES_TLS_DIR}/server-key.pem" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/ca-cert.pem" +} + +replace_postgres_ca() { + "${KUBECTL}" -n "$1" create secret generic postgres-ca \ + --from-file=ca-bundle.pem="$2" --dry-run=client -o yaml | "${KUBECTL}" apply --server-side -f - +} + +# Wait for the expected failure itself, rather than accepting an unrelated +# transient False condition while the provider is switching configurations. +wait_postgres_tls_rejection() { + local namespace="$1" resource="$2" expected="$3" + local deadline=$((SECONDS + 90)) status message + while (( SECONDS < deadline )); do + status=$("${KUBECTL}" -n "${namespace}" get "${resource}" -o 'jsonpath={.status.conditions[?(@.type=="Synced")].status}') + message=$("${KUBECTL}" -n "${namespace}" get "${resource}" -o 'jsonpath={.status.conditions[?(@.type=="Synced")].message}') + if [[ "${status}" == "False" && "${message}" == *"${expected}"* ]]; then + return 0 + fi + sleep 1 + done + echo_error "Expected TLS rejection containing '${expected}', got ${status}: ${message}" +} + +check_postgres_ca_rotation() { + echo_step "Checking PostgreSQL CA Secret rejection and rotation without provider restart" + local db="database.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/db1" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/wrong-ca.pem" + "${KUBECTL}" annotate "${db}" test.sql.crossplane.io/ca-rotation=wrong --overwrite + wait_postgres_tls_rejection default "${db}" "unknown authority" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/ca-cert.pem" + "${KUBECTL}" annotate "${db}" test.sql.crossplane.io/ca-rotation=restored --overwrite + "${KUBECTL}" wait "${db}" --for=condition=Synced=true --timeout=90s + echo_step_completed +} + +check_postgres_cluster_ca_config() { + echo_step "Checking PostgreSQL ClusterProviderConfig CA selector" + "${KUBECTL}" create namespace postgres-ca-trust + replace_postgres_ca postgres-ca-trust "${POSTGRES_TLS_DIR}/ca-cert.pem" + cat <&1); then + if [[ "${rejected}" == "true" ]]; then + echo_error "${kind} accepted ${source} with a CA and sslMode=${mode}" + fi + if [[ "${mode}" == "omitted" && $(jq -r '.spec.sslMode' <<<"${result}") != "verify-full" ]]; then + echo_error "${kind} did not default sslMode to verify-full" + fi + elif [[ "${rejected}" != "true" || "${result}" != *"tlsConfig requires sslMode verify-ca or verify-full"* ]]; then + echo_error "Unexpected PostgreSQL admission failure for ${kind}/${source}/${sample}: ${result}" + fi + count=$((count + 1)) + done + done + done + echo_info "${count} PostgreSQL admission cases passed" + echo_step_completed } diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md new file mode 100644 index 00000000..42effdbe --- /dev/null +++ b/docs/aws-iam-auth.md @@ -0,0 +1,189 @@ +# AWS IAM database authentication + +provider-sql can authenticate to Amazon Aurora / RDS (MySQL and PostgreSQL) +using [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html) +instead of a static password. The provider generates a short-lived IAM token at +each physical connection attempt and uses it as the database password. Combined with EKS Pod +Identity or IRSA, this removes static database credentials entirely. + +Select it with the `AWSIAMAuth` credentials source on a `ProviderConfig`: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional (see "Region resolution") +``` + +See the runnable examples: `examples/{cluster,namespaced}/{mysql,postgresql}/config_iam.yaml`. + +## Prerequisites + +1. **Enable IAM authentication** on the Aurora cluster / RDS instance. + +2. **Create an IAM-enabled database user for the provider to connect as.** This + is a one-time bootstrap that needs a normal (password) connection, performed + out of band - the provider does not do it. Use a **dedicated provisioning + role, not the master user**. In PostgreSQL, granting `rds_iam` gives IAM + authentication precedence over password authentication for that role: + + - PostgreSQL: `CREATE USER crossplane_admin; GRANT rds_iam TO crossplane_admin;` + plus the privileges it needs to manage your databases/roles/grants. + - MySQL: `CREATE USER 'crossplane_admin'@'%' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS';` + plus the required `GRANT`s. + + (Creating these users is also possible declaratively through provider-sql's + own `Role`/`Grant` resources from a password-based `ProviderConfig`.) + +3. **Allow the controller's IAM identity to connect** by attaching an + `rds-db:connect` policy for that database user, and give the provider pod that + identity via [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) + or [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). + Credentials are discovered from the environment by the AWS SDK. + +4. **Provide a connection secret** containing only `endpoint`, `port` and + `username` (no `password`). + +### IAM identity and ProviderConfig permissions + +All IAM ProviderConfigs use the provider pod's AWS identity. A namespace does +not give a ProviderConfig a separate IAM role. Anyone allowed to create an IAM +ProviderConfig and its connection Secret can select any database user that the +pod's `rds-db:connect` policy permits. + +Restrict that policy to the required database resource IDs and usernames, for +example `arn:aws:rds-db:eu-west-1:123456789012:dbuser:db-RESOURCEID/crossplane_admin`, +and restrict who can create or modify ProviderConfigs through Kubernetes RBAC. +Use separate provider installations and IAM roles for independent trust boundaries. +See [the RDS IAM policy format](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html). + +## TLS and the Amazon RDS certificate authority + +Supply the Amazon RDS CA through `tlsConfig.caCert.secretRef` on the +ProviderConfig. Both PostgreSQL and MySQL support a CA bundle without a client +certificate. No provider-pod volume mount, `SSL_CERT_FILE` setting or custom +container image is needed. + +Download the public RDS certificate bundle and create a Secret: + +```sh +curl --fail --silent --show-error \ + https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ + --output rds-global-bundle.pem +kubectl -n crossplane-system create secret generic rds-ca \ + --from-file=ca-bundle.pem=rds-global-bundle.pem \ + --dry-run=client -o yaml | kubectl apply --server-side -f - +``` + +For a cluster-scoped PostgreSQL ProviderConfig: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem +``` + +For a namespaced PostgreSQL ProviderConfig, create the CA Secret in that +ProviderConfig's namespace and omit `namespace` from the selector: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + key: ca-bundle.pem +``` + +ClusterProviderConfigs use an explicit Secret namespace, as in the first example. +The same CA selector works with password authentication. PostgreSQL CA selectors +require `verify-ca` or `verify-full`; other modes are rejected when a selector is +present. This also applies to IAM: `sslMode: require` with `tlsConfig` is invalid; +use `verify-full` (the default) or `verify-ca` instead. `verify-ca` verifies the +certificate chain; `verify-full` also verifies +the endpoint hostname. + +Use `verify-full` for IAM authentication with the actual RDS endpoint hostname. +With `verify-ca`, a broad trust bundle can accept a certificate for a different +server and expose the IAM token. Keep the selected bundle limited to RDS CAs for +RDS connections. See [PostgreSQL's certificate verification guidance](https://www.postgresql.org/docs/current/libpq-ssl.html). + +For MySQL, select `tls: custom` and provide the CA Secret: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system # omit for a namespaced ProviderConfig + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem +``` + +MySQL's existing selectors require an explicit namespace. For a namespaced +ProviderConfig, use a Secret in its own namespace; existing MySQL selectors still +allow cross-namespace references and do not enforce tenant isolation. Restrict +ProviderConfig writes with RBAC. If mutual TLS is needed, provide both MySQL +`clientCert` and `clientKey`; an incomplete pair is rejected. + +Maintain the Secret as RDS CAs rotate. The provider reloads the selected bundle +on each reconcile, so updates take effect on subsequent connections without a +provider restart. [trust-manager](https://cert-manager.io/docs/trust/trust-manager/) +can distribute the bundle to Secrets; configure the selector to read its output. +The provider does not download or embed the RDS CA. + +Without a CA selector, existing driver trust-store behavior is preserved. +PostgreSQL's API defaults an omitted `sslMode` to `verify-full`; IAM preserves +`require`, `verify-ca` and `verify-full`, and upgrades `disable` to `require`. +`require` only encrypts unless lib/pq finds an explicit root certificate file; +loading system roots through `SSL_CERT_FILE` alone does not enable verification +in that mode. MySQL IAM upgrades omitted or `preferred` TLS to verified `true`; +that mode requires the CA in the driver's system pool. Explicit `skip-verify` +remains an operator choice. Kubernetes rejects explicit empty PostgreSQL modes +and MySQL empty or `false` modes. + +## Region resolution + +The region used to sign the token is resolved in order: + +1. `spec.credentials.region` on the `ProviderConfig` +2. a `region` key in the connection secret +3. the AWS SDK's configured region (`AWS_REGION`, `AWS_DEFAULT_REGION`, or the + selected shared AWS profile) + +If none is set the token cannot be signed correctly, so configure one of them. +Region discovery from EC2 instance metadata is not enabled. + +## Notes + +- IAM tokens are valid for 15 minutes. The provider signs a fresh token immediately + before each physical database connection, including reconnects within a long + reconcile. An already authenticated session continues after its token expires; + it does not need to be interrupted or reauthenticated. The AWS credentials used + for signing must also be valid; the SDK refreshes renewable credential sources. + The provider reuses its AWS configuration and credential cache across reconciles; + restart it after changing environment variables or shared AWS profile settings. +- Setting `AWSIAMAuth` forces TLS on even if a weaker `tls`/`sslMode` was set. diff --git a/examples/cluster/mysql/config_iam.yaml b/examples/cluster/mysql/config_iam.yaml new file mode 100644 index 00000000..8886c8bb --- /dev/null +++ b/examples/cluster/mysql/config_iam.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # Verify the server using the CA Secret; no client certificate or pod mount is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem + +# The connection secret holds only endpoint, port and username - no password. +# The provider generates a short-lived IAM token and uses it as the password. +# apiVersion: v1 +# kind: Secret +# metadata: +# name: aurora-conn +# namespace: crossplane-system +# stringData: +# endpoint: my-cluster.cluster-xxxxxxxx.us-east-1.rds.amazonaws.com +# port: "3306" +# username: crossplane_admin diff --git a/examples/cluster/mysql/config_iam_custom_ca.yaml b/examples/cluster/mysql/config_iam_custom_ca.yaml new file mode 100644 index 00000000..514cc206 --- /dev/null +++ b/examples/cluster/mysql/config_iam_custom_ca.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam-custom-ca +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # The CA is loaded from this Secret; no pod trust mount or client cert is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem diff --git a/examples/cluster/postgresql/config_iam.yaml b/examples/cluster/postgresql/config_iam.yaml new file mode 100644 index 00000000..a56e4522 --- /dev/null +++ b/examples/cluster/postgresql/config_iam.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + # defaultDatabase: postgres + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # Verify the server using the CA Secret; no pod trust mount is needed. + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem + +# The connection secret holds only endpoint, port and username - no password. +# The provider generates a short-lived IAM token and uses it as the password. +# apiVersion: v1 +# kind: Secret +# metadata: +# name: aurora-conn +# namespace: crossplane-system +# stringData: +# endpoint: my-cluster.cluster-xxxxxxxx.us-east-1.rds.amazonaws.com +# port: "5432" +# username: crossplane_admin diff --git a/examples/namespaced/mysql/config_iam.yaml b/examples/namespaced/mysql/config_iam.yaml new file mode 100644 index 00000000..f307c91b --- /dev/null +++ b/examples/namespaced/mysql/config_iam.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # Verify the server using the CA Secret; no client certificate or pod mount is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: default + key: ca-bundle.pem diff --git a/examples/namespaced/mysql/config_iam_custom_ca.yaml b/examples/namespaced/mysql/config_iam_custom_ca.yaml new file mode 100644 index 00000000..56e97702 --- /dev/null +++ b/examples/namespaced/mysql/config_iam_custom_ca.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam-custom-ca + namespace: default +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # The CA is loaded from this Secret; no pod trust mount or client cert is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: default # keep the CA Secret in the ProviderConfig's namespace + key: ca-bundle.pem diff --git a/examples/namespaced/postgresql/clusterconfig_iam.yaml b/examples/namespaced/postgresql/clusterconfig_iam.yaml new file mode 100644 index 00000000..f046c7e3 --- /dev/null +++ b/examples/namespaced/postgresql/clusterconfig_iam.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ClusterProviderConfig +metadata: + name: aurora-iam +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem diff --git a/examples/namespaced/postgresql/config_iam.yaml b/examples/namespaced/postgresql/config_iam.yaml new file mode 100644 index 00000000..883cf51c --- /dev/null +++ b/examples/namespaced/postgresql/config_iam.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + # defaultDatabase: postgres + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # Verify the server using the CA Secret; no pod trust mount is needed. + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + key: ca-bundle.pem diff --git a/go.mod b/go.mod index 170d3fca..71932a99 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.8 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.25 + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29 github.com/crossplane/crossplane-runtime/v2 v2.4.0 github.com/crossplane/crossplane-tools v0.0.0-20260719180100-659f1dc036c5 github.com/crossplane/crossplane/apis/v2 v2.4.0 @@ -32,6 +35,18 @@ require ( github.com/antchfx/htmlquery v1.2.4 // indirect github.com/antchfx/xpath v1.3.6 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index d697d38f..e62eb2bc 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,36 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29 h1:1Hbcvm9a/7hBCAM5y5SAvSKyFUsULrMgmS+XBx32u68= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29/go.mod h1:qIWWBwh4Wp7HU4E8AkMtu9pqHJ6DxmqLFX7zV8ZX4lM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= diff --git a/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml index 5b7c0366..9de8b4f3 100644 --- a/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml +++ b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml @@ -70,6 +70,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -97,6 +103,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml index cd936235..5d69232c 100644 --- a/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml @@ -71,6 +71,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -98,6 +104,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml index 26d83ed6..9ad619ef 100644 --- a/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml @@ -66,6 +66,12 @@ spec: required: - name type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -93,6 +99,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml index b6e9c167..41bfa61b 100644 --- a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml @@ -70,6 +70,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -97,6 +103,7 @@ spec: description: Source of the provider credentials. enum: - PostgreSQLConnectionSecret + - AWSIAMAuth type: string required: - source @@ -118,9 +125,46 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. Namespace, name + and key are required. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ProviderConfigStatus reflects the observed state of a ProviderConfig. properties: diff --git a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml index 35a5950a..91157834 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml @@ -71,6 +71,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -98,6 +104,7 @@ spec: description: Source of the provider credentials. enum: - PostgreSQLConnectionSecret + - AWSIAMAuth type: string required: - source @@ -119,9 +126,46 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. Namespace, name + and key are required. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ClusterProviderConfigStatus reflects the observed state of a ClusterProviderConfig. diff --git a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml index 0be259a2..73cdf24c 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml @@ -66,6 +66,12 @@ spec: required: - name type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -93,6 +99,7 @@ spec: description: Source of the provider credentials. enum: - PostgreSQLConnectionSecret + - AWSIAMAuth type: string required: - source @@ -114,9 +121,41 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. The Secret must + be in the ProviderConfig namespace. + properties: + key: + type: string + name: + description: Name of the secret. + type: string + required: + - key + - name + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ProviderConfigStatus reflects the observed state of a ProviderConfig. properties: diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go new file mode 100644 index 00000000..2f7a7cdc --- /dev/null +++ b/pkg/clients/awsiam/awsiam.go @@ -0,0 +1,149 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package awsiam generates short-lived AWS RDS IAM authentication tokens and +// supplies them in place of a static database password. It lets provider-sql +// connect to Aurora/RDS using an IAM identity (EKS Pod Identity or IRSA) +// instead of a password stored in a Kubernetes Secret. +package awsiam + +import ( + "context" + "net" + "sync" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/pkg/errors" +) + +// regionKey is the optional connection-secret key used to resolve the AWS +// region when it is not set on the ProviderConfig. +const regionKey = "region" + +const ( + errMissingConnDetails = "connection secret must contain endpoint, port and username for AWS IAM authentication" + errNoRegion = "AWS region could not be resolved: set spec.credentials.region, a \"region\" key in the connection secret, or the controller's AWS region (e.g. AWS_REGION)" + errBuildToken = "cannot generate AWS RDS IAM authentication token" + errLoadConfig = "cannot load AWS configuration" +) + +// TokenBuilder has the same signature as auth.BuildAuthToken from the AWS SDK. +// The token provider takes it as a parameter so production code passes the real +// auth.BuildAuthToken while tests pass a stub that returns a fixed token +// without calling AWS. +type TokenBuilder func(ctx context.Context, endpoint, region, dbUser string, + creds aws.CredentialsProvider, optFns ...func(*auth.BuildAuthTokenOptions)) (string, error) + +// ResolveRegion picks the AWS region to sign the token with, in priority order: +// the ProviderConfig field, then a "region" key in the connection secret, then +// the region the AWS SDK discovered from the environment (cfgRegion). The result +// may be empty if none of the three is set; callers must treat that as an error, +// because the SDK does not reject an empty region at token-generation time. +func ResolveRegion(specRegion *string, creds map[string][]byte, cfgRegion string) string { + if specRegion != nil && *specRegion != "" { + return *specRegion + } + if r := string(creds[regionKey]); r != "" { + return r + } + return cfgRegion +} + +type awsConfigCache struct { + mu sync.Mutex + config *aws.Config + loading chan struct{} + load func(context.Context) (aws.Config, error) +} + +func (c *awsConfigCache) get(ctx context.Context) (aws.Config, error) { + for { + if err := ctx.Err(); err != nil { + return aws.Config{}, err + } + c.mu.Lock() + if c.config != nil { + cfg := *c.config + c.mu.Unlock() + return cfg, nil + } + if c.loading != nil { + ready := c.loading + c.mu.Unlock() + select { + case <-ctx.Done(): + return aws.Config{}, ctx.Err() + case <-ready: + continue + } + } + c.loading = make(chan struct{}) + c.mu.Unlock() + + cfg, err := c.load(ctx) + c.mu.Lock() + if err == nil { + c.config = &cfg + } + close(c.loading) + c.loading = nil + c.mu.Unlock() + return cfg, err + } +} + +var defaultConfigCache = awsConfigCache{load: func(ctx context.Context) (aws.Config, error) { + return config.LoadDefaultConfig(ctx) +}} + +// NewTokenProvider reuses the process's AWS configuration and signs a new token for +// every physical connection. The SDK credential provider refreshes temporary +// AWS credentials as needed. No token is retained in a long-lived DSN. +func NewTokenProvider(ctx context.Context, specRegion *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + cfg, err := defaultConfigCache.get(ctx) + if err != nil { + return nil, errors.Wrap(err, errLoadConfig) + } + return tokenProvider(creds, ResolveRegion(specRegion, creds, cfg.Region), cfg.Credentials, auth.BuildAuthToken) +} + +func tokenProvider(creds map[string][]byte, region string, awsCreds aws.CredentialsProvider, build TokenBuilder) (xsql.PasswordProvider, error) { + endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) + port := string(creds[xpv2.CredentialsSecretPortKey]) + username := string(creds[xpv2.CredentialsSecretUserKey]) + if endpoint == "" || port == "" || username == "" { + return nil, errors.New(errMissingConnDetails) + } + if region == "" { + return nil, errors.New(errNoRegion) + } + address := net.JoinHostPort(endpoint, port) + return func(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + token, err := build(ctx, address, region, username, awsCreds) + if err != nil { + return "", errors.Wrap(err, errBuildToken) + } + return token, nil + }, nil +} diff --git a/pkg/clients/awsiam/awsiam_test.go b/pkg/clients/awsiam/awsiam_test.go new file mode 100644 index 00000000..dd75ee58 --- /dev/null +++ b/pkg/clients/awsiam/awsiam_test.go @@ -0,0 +1,342 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsiam + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +func TestAWSConfigCache(t *testing.T) { + var calls atomic.Int32 + want := errors.New("temporary config failure") + cache := awsConfigCache{load: func(context.Context) (aws.Config, error) { + if calls.Add(1) == 1 { + return aws.Config{}, want + } + return aws.Config{Region: "eu-west-1"}, nil + }} + if _, err := cache.get(context.Background()); !errors.Is(err, want) { + t.Fatalf("expected first load failure, got %v", err) + } + var workers sync.WaitGroup + for range 20 { + workers.Go(func() { + cfg, err := cache.get(context.Background()) + if err != nil || cfg.Region != "eu-west-1" { + t.Errorf("cached config: region=%q error=%v", cfg.Region, err) + } + }) + } + workers.Wait() + if got := calls.Load(); got != 2 { + t.Fatalf("expected one failure then one shared successful load, got %d", got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cache.get(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled config request: %v", err) + } +} + +type configWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *configWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +func TestAWSConfigCacheCanceledWaiter(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + cache := awsConfigCache{load: func(context.Context) (aws.Config, error) { + close(started) + <-release + return aws.Config{Region: "eu-west-1"}, nil + }} + loaded := make(chan error, 1) + go func() { _, err := cache.get(context.Background()); loaded <- err }() + <-started + t.Cleanup(func() { close(release); <-loaded }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + waiter := &configWaitContext{Context: ctx, waiting: make(chan struct{})} + result := make(chan error, 1) + go func() { _, err := cache.get(waiter); result <- err }() + select { + case <-waiter.waiting: + case <-time.After(5 * time.Second): + t.Fatal("second caller did not wait on the active config load") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("waiting caller returned %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled caller remained blocked on another caller's config load") + } +} + +func TestNewTokenProviderWithSDKEnvironment(t *testing.T) { + // Keep the process-wide cache and the developer's AWS configuration isolated. + if os.Getenv("PROVIDER_SQL_AWS_CONFIG_TEST") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestNewTokenProviderWithSDKEnvironment$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_AWS_CONFIG_TEST=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK config subprocess: %v\n%s", err, out) + } + return + } + for name, value := range map[string]string{ + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + "AWS_ACCESS_KEY_ID": "SYNTHETICACCESS", "AWS_SECRET_ACCESS_KEY": "synthetic-secret", + "AWS_SESSION_TOKEN": "", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "", + "AWS_EC2_METADATA_DISABLED": "true", "AWS_WEB_IDENTITY_TOKEN_FILE": "", "AWS_ROLE_ARN": "", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "", "AWS_CONTAINER_CREDENTIALS_FULL_URI": "", + } { + t.Setenv(name, value) + } + creds := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + } + for _, region := range []string{"", "eu-central-1"} { + provider, err := NewTokenProvider(context.Background(), ®ion, creds) + if err != nil { + t.Fatal(err) + } + token, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse("https://" + token) + if err != nil { + t.Fatal(err) + } + wantRegion := region + if wantRegion == "" { + wantRegion = "eu-west-1" + } + if parsed.Query().Get("DBUser") != "user" || !strings.Contains(parsed.Query().Get("X-Amz-Credential"), "/"+wantRegion+"/rds-db/") { + t.Fatal("SDK token did not use the configured user and region") + } + } +} + +// builderCall records what a stub TokenBuilder was invoked with, so a test can +// assert on it afterwards. +type builderCall struct { + endpoint string + region string + dbUser string + called bool +} + +// recordingBuilder returns a TokenBuilder that records its inputs and returns +// the supplied token/err. This is the "fake AWS": it never calls AWS, so the +// tests are deterministic and need no credentials or network. +func recordingBuilder(token string, err error, rec *builderCall) TokenBuilder { + return func(_ context.Context, endpoint, region, dbUser string, _ aws.CredentialsProvider, _ ...func(*auth.BuildAuthTokenOptions)) (string, error) { + rec.endpoint, rec.region, rec.dbUser, rec.called = endpoint, region, dbUser, true + return token, err + } +} + +func TestResolveRegion(t *testing.T) { + ptr := func(s string) *string { return &s } + + cases := map[string]struct { + specRegion *string + creds map[string][]byte + cfgRegion string + want string + }{ + "SpecFieldWins": { + specRegion: ptr("eu-west-1"), + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "eu-west-1", + }, + "SecretWhenSpecNil": { + specRegion: nil, + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "us-east-1", + }, + "SecretWhenSpecEmpty": { + specRegion: ptr(""), + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "us-east-1", + }, + "ConfigWhenNothingElse": { + specRegion: nil, + creds: nil, + cfgRegion: "ap-south-1", + want: "ap-south-1", + }, + "EmptyWhenAllEmpty": { + specRegion: nil, + creds: nil, + cfgRegion: "", + want: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := ResolveRegion(tc.specRegion, tc.creds, tc.cfgRegion); got != tc.want { + t.Errorf("ResolveRegion(...): want %q, got %q", tc.want, got) + } + }) + } +} + +func TestTokenProvider(t *testing.T) { + creds := map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), xpv2.CredentialsSecretPortKey: []byte("5432"), xpv2.CredentialsSecretUserKey: []byte("admin")} + now := time.Unix(0, 0) + calls := 0 + build := func(ctx context.Context, host, region, user string, _ aws.CredentialsProvider, _ ...func(*auth.BuildAuthTokenOptions)) (string, error) { + if host != "db.example.com:5432" || region != "eu-west-1" || user != "admin" { + t.Error("incorrect signing inputs") + } + calls++ + return fmt.Sprintf("token-%d", now.Unix()), nil + } + provider, err := tokenProvider(creds, "eu-west-1", nil, build) + if err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatal("must not sign a token before a physical connection needs it") + } + first, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + now = now.Add(20 * time.Minute) + // The closure captures immutable connection data, not the mutable Secret map. + creds[xpv2.CredentialsSecretEndpointKey][0] = 'X' + next, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if first == next || calls != 2 { + t.Fatal("later connection reused the initial token") + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be written into the Secret map") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := provider(ctx); err != context.Canceled { + t.Fatalf("expected cancellation, got %v", err) + } + if calls != 2 { + t.Error("cancellation must prevent signing") + } +} + +func TestTokenProviderValidationAndErrors(t *testing.T) { + for _, missing := range []string{"endpoint", "port", "username", "region"} { + t.Run(missing, func(t *testing.T) { + creds := map[string][]byte{"endpoint": []byte("db"), "port": []byte("3306"), "username": []byte("user")} + region := "eu-west-1" + if missing == "region" { + region = "" + } else { + delete(creds, missing) + } + if _, err := tokenProvider(creds, region, nil, nil); err == nil { + t.Error("expected validation failure") + } + }) + } + rec := &builderCall{} + provider, err := tokenProvider(map[string][]byte{"endpoint": []byte("2001:db8::1"), "port": []byte("5432"), "username": []byte("user")}, "eu-west-1", nil, recordingBuilder("", fmt.Errorf("signing failed"), rec)) + if err != nil { + t.Fatal(err) + } + if _, err := provider(context.Background()); err == nil { + t.Error("signing error was swallowed") + } + if rec.endpoint != "[2001:db8::1]:5432" { + t.Fatalf("invalid IPv6 host/port: %q", rec.endpoint) + } +} + +func TestTokenProviderRefreshesAWSCredentials(t *testing.T) { + calls := 0 + fail := false + boom := errors.New("AWS credentials unavailable") + cache := aws.NewCredentialsCache(aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { + if fail { + return aws.Credentials{}, boom + } + calls++ + return aws.Credentials{AccessKeyID: fmt.Sprintf("TESTKEY%d", calls), SecretAccessKey: "test-secret", SessionToken: "test-session", CanExpire: true, Expires: time.Now().Add(time.Hour)}, nil + })) + provider, err := tokenProvider(map[string][]byte{"endpoint": []byte("db.example.com"), "port": []byte("3306"), "username": []byte("user")}, "eu-west-1", cache, auth.BuildAuthToken) + if err != nil { + t.Fatal(err) + } + first, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(first, "TESTKEY1") { + t.Fatal("token did not use initial AWS credentials") + } + cache.Invalidate() + second, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(second, "TESTKEY2") { + t.Fatal("token reused captured AWS credentials") + } + fail = true + cache.Invalidate() + if _, err := provider(context.Background()); !errors.Is(err, boom) { + t.Fatalf("credential error was lost: %v", err) + } + fail = false + if _, err := provider(context.Background()); err != nil { + t.Fatalf("provider failed to recover: %v", err) + } +} diff --git a/pkg/clients/awsiam/config_test.go b/pkg/clients/awsiam/config_test.go new file mode 100644 index 00000000..1b921055 --- /dev/null +++ b/pkg/clients/awsiam/config_test.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsiam + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +func TestNewTokenProviderSharesRenewableSDKCredentials(t *testing.T) { + if os.Getenv("PROVIDER_SQL_ENDPOINT_CREDENTIALS_TEST") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestNewTokenProviderSharesRenewableSDKCredentials$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_ENDPOINT_CREDENTIALS_TEST=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK credential subprocess: %v\n%s", err, out) + } + return + } + var requests, version atomic.Int32 + version.Store(1) + var authorization atomic.Value + authorization.Store("pod-token-one") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Authorization") != authorization.Load().(string) { + http.Error(w, "wrong workload token", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "AccessKeyId": fmt.Sprintf("KEY%d", version.Load()), "SecretAccessKey": "synthetic-secret", + "Token": "synthetic-session", "Expiration": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "pod-token") + if err := os.WriteFile(tokenFile, []byte("pod-token-one"), 0600); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "", + "AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_KEY": "", "AWS_SESSION_TOKEN": "", + "AWS_EC2_METADATA_DISABLED": "true", "AWS_WEB_IDENTITY_TOKEN_FILE": "", "AWS_ROLE_ARN": "", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "", "AWS_CONTAINER_CREDENTIALS_FULL_URI": server.URL, + "AWS_CONTAINER_AUTHORIZATION_TOKEN": "", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE": tokenFile, + } { + t.Setenv(name, value) + } + details := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), xpv2.CredentialsSecretUserKey: []byte("user"), + } + initialCtx, cancel := context.WithCancel(context.Background()) + first, err := NewTokenProvider(initialCtx, nil, details) + cancel() + if err != nil { + t.Fatal(err) + } + assertToken := func(provider func(context.Context) (string, error), key string) { + t.Helper() + token, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse("https://" + token) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(parsed.Query().Get("X-Amz-Credential"), key+"/") { + t.Fatalf("token did not use renewed key %s", key) + } + } + assertToken(first, "KEY1") // The initializer's canceled context must not survive in the SDK provider. + second, err := NewTokenProvider(context.Background(), nil, details) + if err != nil { + t.Fatal(err) + } + assertToken(second, "KEY1") + if got := requests.Load(); got != 1 { + t.Fatalf("two reconciles fetched AWS credentials %d times", got) + } + + if err := os.WriteFile(tokenFile, []byte("pod-token-two"), 0600); err != nil { + t.Fatal(err) + } + authorization.Store("pod-token-two") + version.Store(2) + cfg, err := defaultConfigCache.get(context.Background()) + if err != nil { + t.Fatal(err) + } + cache, ok := cfg.Credentials.(*aws.CredentialsCache) + if !ok { + t.Fatalf("expected SDK credentials cache, got %T", cfg.Credentials) + } + cache.Invalidate() + assertToken(second, "KEY2") + assertToken(first, "KEY2") + if got := requests.Load(); got != 2 { + t.Fatalf("expected one initial retrieval and one renewal, got %d", got) + } +} diff --git a/pkg/clients/mysql/custom_tls_test.go b/pkg/clients/mysql/custom_tls_test.go new file mode 100644 index 00000000..12a30d31 --- /dev/null +++ b/pkg/clients/mysql/custom_tls_test.go @@ -0,0 +1,165 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mysql_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + cluster "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" + clusterTLS "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + modernTLS "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + driverMySQL "github.com/go-sql-driver/mysql" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func testCA(t *testing.T, name string) ([]byte, []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) +} + +func TestCustomTLSCAOnlyAndClientPair(t *testing.T) { + ca1, key1 := testCA(t, "database-one") + ca2, _ := testCA(t, "database-two") + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "one", Namespace: "certs"}, Data: map[string][]byte{"ca": ca1, "cert": ca1, "key": key1, "bad": []byte("invalid PEM")}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "two", Namespace: "certs"}, Data: map[string][]byte{"ca": ca2}}, + ).Build() + ref := func(name, key string) xpv2.SecretKeySelector { + return xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: name, Namespace: "certs"}, Key: key} + } + type loadFunc func(context.Context, client.Client, string, *string, *cluster.TLSConfig) (*string, error) + loaders := map[string]loadFunc{ + "cluster": clusterTLS.LoadConfig, + "namespaced": func(ctx context.Context, k client.Client, name string, mode *string, cfg *cluster.TLSConfig) (*string, error) { + var next *modern.TLSConfig + if cfg != nil { + next = &modern.TLSConfig{CACert: modern.TLSSecret{SecretRef: cfg.CACert.SecretRef}, ClientCert: modern.TLSSecret{SecretRef: cfg.ClientCert.SecretRef}, ClientKey: modern.TLSSecret{SecretRef: cfg.ClientKey.SecretRef}, InsecureSkipVerify: cfg.InsecureSkipVerify} + } + return modernTLS.LoadConfig(ctx, k, name, mode, next) + }, + } + for scope, load := range loaders { + t.Run(scope, func(t *testing.T) { + mode := "custom" + caOnly := &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "ca")}} + name, err := load(context.Background(), kube, "ProviderConfig/ns-one/same", &mode, caOnly) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*name) + // Loading another database must not replace the first database's CA pool. + other, err := load(context.Background(), kube, "ProviderConfig/ns-two/same", &mode, &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("two", "ca")}}) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*other) + if *name == *other { + t.Fatal("distinct ProviderConfigs share a TLS registration") + } + for _, tc := range []struct { + name string + ca []byte + }{{*name, ca1}, {*other, ca2}} { + cfg, err := driverMySQL.ParseDSN("user:token@tcp(db.example.com:3306)/?tls=" + tc.name) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(tc.ca) + if !cfg.TLS.RootCAs.Equal(pool) { + t.Fatal("incorrect per-database CA pool") + } + if cfg.TLS.InsecureSkipVerify || len(cfg.TLS.Certificates) != 0 || cfg.TLS.ServerName != "db.example.com" { + t.Fatal("CA-only configuration must verify hostname without a client certificate") + } + } + pair := *caOnly + pair.ClientCert.SecretRef = ref("one", "cert") + pair.ClientKey.SecretRef = ref("one", "key") + name, err = load(context.Background(), kube, "mutual-tls", &mode, &pair) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*name) + cfg, err := driverMySQL.ParseDSN("user:password@tcp(db.example.com:3306)/?tls=" + *name) + if err != nil { + t.Fatal(err) + } + if len(cfg.TLS.Certificates) != 1 { + t.Fatal("existing mutual TLS configuration lost its client certificate") + } + for _, tc := range []struct { + name string + cfg *cluster.TLSConfig + want string + }{ + {"missing config", nil, "caCert"}, + {"missing CA", &cluster.TLSConfig{}, "caCert"}, + {"cert without key", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert}, "together"}, + {"key without cert", &cluster.TLSConfig{CACert: caOnly.CACert, ClientKey: pair.ClientKey}, "together"}, + {"partial reference", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert, ClientKey: cluster.TLSSecret{SecretRef: ref("one", "")}}, "complete"}, + {"missing secret", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("missing", "ca")}}, "cannot get CA"}, + {"missing key", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "missing")}}, "cannot get CA"}, + {"invalid CA", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "bad")}}, "cannot append"}, + {"invalid client key", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert, ClientKey: cluster.TLSSecret{SecretRef: ref("one", "bad")}}, "cannot make client"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := load(context.Background(), kube, tc.name, &mode, tc.cfg); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } + plain := "true" + if _, err := load(context.Background(), kube, "invalid-mode", &plain, caOnly); err == nil { + t.Error("tlsConfig must still require custom mode") + } + }) + } +} diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index da4b94c4..75bff69d 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -3,10 +3,13 @@ package mysql import ( "context" "database/sql" + "database/sql/driver" "fmt" "strconv" "strings" + driverMySQL "github.com/go-sql-driver/mysql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/pkg/errors" @@ -16,17 +19,21 @@ import ( const ( errNotSupported = "%s not supported by mysql client" + tlsVerify = "true" ) type mySQLDB struct { - dsn string - endpoint string - port string - tls string + dsn string + endpoint string + port string + tls string + passwordProvider xsql.PasswordProvider } -// New returns a new MySQL database client. -func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { +// New returns a new MySQL database client. When cleartext is true the DSN sets +// allowCleartextPasswords=true, which is required for AWS RDS IAM authentication +// (the AWSAuthenticationPlugin uses MySQL's cleartext client plugin). +func New(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB { endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) @@ -35,7 +42,7 @@ func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { defaultTLS := "preferred" tls = &defaultTLS } - dsn := DSN(username, password, endpoint, port, *tls, binlog) + dsn := DSN(username, password, endpoint, port, *tls, binlog, cleartext) return mySQLDB{ dsn: dsn, @@ -45,26 +52,54 @@ func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { } } -// DSN returns the DSN URL -func DSN(username, password, endpoint, port, tls string, binlog *bool) string { - // Use net/url UserPassword to encode the username and password - // This will ensure that any special characters in the username or password - // are percent-encoded for use in the user info portion of the DSN URL +// NewWithPasswordProvider configures credentials generated for each physical connection. +func NewWithPasswordProvider(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB { + db := New(creds, tls, binlog, cleartext).(mySQLDB) + db.passwordProvider = password + return db +} + +func (c mySQLDB) open() (*sql.DB, error) { + if c.passwordProvider == nil { + return sql.Open("mysql", c.dsn) + } + cfg, err := driverMySQL.ParseDSN(c.dsn) + if err != nil { + return nil, err + } + return sql.OpenDB(&xsql.PasswordConnector{ + Password: c.passwordProvider, + SQLDriver: &driverMySQL.MySQLDriver{}, + NewConnector: func(password string) (driver.Connector, error) { + next := cfg.Clone() + next.Passwd = password + return driverMySQL.NewConnector(next) + }, + }), nil +} + +// EnsureTLS selects verified TLS for IAM when mode is unset or permits plaintext. +// Explicit skip-verify and registered custom configurations remain operator choices. +func EnsureTLS(mode *string) *string { + if mode == nil || *mode == "" || *mode == "false" || *mode == "preferred" { + verified := tlsVerify + return &verified + } + return mode +} + +// DSN returns the DSN URL. When cleartext is true, allowCleartextPasswords=true +// is appended (required for AWS RDS IAM authentication). +func DSN(username, password, endpoint, port, tls string, binlog *bool, cleartext bool) string { + // MySQL DSN passwords are passed verbatim. URL-encoding would change IAM tokens. + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", username, password, endpoint, port, tls) if binlog != nil { - return fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s&sql_log_bin=%s", - username, - password, - endpoint, - port, - tls, - strconv.FormatBool(*binlog)) + dsn += "&sql_log_bin=" + strconv.FormatBool(*binlog) + } + if cleartext { + dsn += "&allowCleartextPasswords=true" } - return fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", - username, - password, - endpoint, - port, - tls) + return dsn } // ExecTx is unsupported in MySQL. @@ -74,7 +109,7 @@ func (c mySQLDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Exec the supplied query. func (c mySQLDB) Exec(ctx context.Context, q xsql.Query) error { - d, err := sql.Open("mysql", c.dsn) + d, err := c.open() if err != nil { return err } @@ -86,7 +121,7 @@ func (c mySQLDB) Exec(ctx context.Context, q xsql.Query) error { // Query the supplied query. func (c mySQLDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { - d, err := sql.Open("mysql", c.dsn) + d, err := c.open() if err != nil { return nil, err } @@ -98,7 +133,7 @@ func (c mySQLDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { // Scan the results of the supplied query into the supplied destination. func (c mySQLDB) Scan(ctx context.Context, q xsql.Query, dest ...interface{}) error { - db, err := sql.Open("mysql", c.dsn) + db, err := c.open() if err != nil { return err } diff --git a/pkg/clients/mysql/mysql_test.go b/pkg/clients/mysql/mysql_test.go index 13aacdc6..8fa02c23 100644 --- a/pkg/clients/mysql/mysql_test.go +++ b/pkg/clients/mysql/mysql_test.go @@ -4,6 +4,8 @@ import ( "fmt" "strconv" "testing" + + driverMySQL "github.com/go-sql-driver/mysql" ) func TestDSNURLEscaping(t *testing.T) { @@ -13,7 +15,7 @@ func TestDSNURLEscaping(t *testing.T) { rawPass := "password^" tls := "true" binlog := false - dsn := DSN(user, rawPass, endpoint, port, tls, &binlog) + dsn := DSN(user, rawPass, endpoint, port, tls, &binlog, false) if dsn != fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s&sql_log_bin=%s", user, rawPass, @@ -31,7 +33,7 @@ func TestDSNURLEscapingWithoutBinLog(t *testing.T) { user := "username" rawPass := "password^" tls := "true" - dsn := DSN(user, rawPass, endpoint, port, tls, nil) + dsn := DSN(user, rawPass, endpoint, port, tls, nil, false) if dsn != fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", user, rawPass, @@ -41,3 +43,57 @@ func TestDSNURLEscapingWithoutBinLog(t *testing.T) { t.Errorf("DSN string did not match expected output with URL encoded") } } + +func TestDSNCleartext(t *testing.T) { + base := "username:password@tcp(endpoint:3306)/?tls=true" + + // cleartext=true appends allowCleartextPasswords=true (required for IAM auth). + if got := DSN("username", "password", "endpoint", "3306", "true", nil, true); got != base+"&allowCleartextPasswords=true" { + t.Errorf("cleartext DSN: want %q, got %q", base+"&allowCleartextPasswords=true", got) + } + // cleartext=false must NOT append it (guards existing non-IAM users). + if got := DSN("username", "password", "endpoint", "3306", "true", nil, false); got != base { + t.Errorf("non-cleartext DSN: want %q, got %q", base, got) + } +} + +func TestEnsureTLS(t *testing.T) { + for _, mode := range []string{"unset", "", "false", "preferred", "true", "skip-verify", "custom-example"} { + t.Run(mode, func(t *testing.T) { + var input *string + if mode != "unset" { + input = &mode + } + want := mode + if input == nil || mode == "" || mode == "false" || mode == "preferred" { + want = "true" + } + got := EnsureTLS(input) + if got == nil || *got != want { + t.Fatalf("want %q, got %v", want, got) + } + if want == mode && got != input { + t.Error("explicit setting should be preserved") + } + if want == "true" { + cfg, err := driverMySQL.ParseDSN(DSN("user", "token", "db.example.com", "3306", *got, nil, true)) + if err != nil { + t.Fatal(err) + } + if cfg.TLS == nil || cfg.TLS.InsecureSkipVerify || cfg.AllowFallbackToPlaintext { + t.Fatal("default IAM mode must verify TLS without plaintext fallback") + } + if cfg.TLS.ServerName != "db.example.com" { + t.Errorf("missing hostname: %q", cfg.TLS.ServerName) + } + } + }) + } +} + +func TestPasswordAuthenticationTLSDefaultUnchanged(t *testing.T) { + db := New(map[string][]byte{}, nil, nil, false).(mySQLDB) + if db.tls != "preferred" { + t.Fatalf("non-IAM default changed to %q", db.tls) + } +} diff --git a/pkg/clients/mysql/tls_test.go b/pkg/clients/mysql/tls_test.go new file mode 100644 index 00000000..83ffaec1 --- /dev/null +++ b/pkg/clients/mysql/tls_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mysql + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// Exercise the real MySQL driver and its TLS negotiation, without AWS or a +// database process. The server returns a recognizable error after receiving +// the authentication packet so the test proves TLS and password injection ran. +func TestIAMTLSNegotiation(t *testing.T) { + if os.Getenv("PROVIDER_SQL_MYSQL_TLS_CHILD") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestIAMTLSNegotiation$", "-test.v", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_MYSQL_TLS_CHILD=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("TLS subprocess: %v\n%s", err, out) + } + return + } + dir := t.TempDir() + t.Setenv("SSL_CERT_FILE", filepath.Join(dir, "ca.pem")) + t.Setenv("SSL_CERT_DIR", dir) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ca := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("SSL_CERT_FILE"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, mode string + trusted, matching bool + want string + }{ + {"omitted verifies", "unset", true, true, "TLS accepted"}, + {"omitted rejects untrusted", "unset", false, true, "unknown authority"}, + {"empty rejects untrusted", "", false, true, "unknown authority"}, + {"false rejects untrusted", "false", false, true, "unknown authority"}, + {"preferred rejects untrusted", "preferred", false, true, "unknown authority"}, + {"default rejects hostname mismatch", "unset", true, false, "not 127.0.0.1"}, + {"explicit unverified opt-in", "skip-verify", false, false, "TLS accepted"}, + } { + t.Run(tc.name, func(t *testing.T) { + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ip := "127.0.0.1" + if !tc.matching { + ip = "127.0.0.2" + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP(ip)}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature} + parent, signer := ca, key + if !tc.trusted { + parent, signer = leaf, leafKey + } + der, err := x509.CreateCertificate(rand.Reader, leaf, parent, &leafKey.PublicKey, signer) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan bool, 1) + go func() { + accepted := false + defer func() { done <- accepted }() + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + const capabilities uint32 = 1 | 512 | 2048 | 8192 | 32768 | 524288 + greeting := append([]byte{10}, []byte("8.4-test\x00")...) + greeting = append(greeting, 1, 0, 0, 0) + greeting = append(greeting, []byte("12345678\x00")...) + greeting = append(greeting, byte(capabilities&255), byte((capabilities>>8)&255), 45, 2, 0, byte((capabilities>>16)&255), byte((capabilities>>24)&255), 21) + greeting = append(greeting, make([]byte, 10)...) + greeting = append(greeting, []byte("123456789012\x00mysql_clear_password\x00")...) + if writeMySQLTestPacket(conn, 0, greeting) != nil { + return + } + request, err := readMySQLTestPacket(conn) + if err != nil || len(request) != 32 { + return + } + if binary.LittleEndian.Uint32(request)&2048 == 0 { + return + } + secure := tls.Server(conn, &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: leafKey}}}) + if secure.Handshake() != nil { + return + } + auth, err := readMySQLTestPacket(secure) + if err != nil { + return + } + accepted = strings.Contains(string(auth), "fresh-token") + _ = writeMySQLTestPacket(secure, 3, append([]byte{255, 0x15, 0x04, '#', '2', '8', '0', '0', '0'}, []byte("TLS accepted")...)) + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + var mode *string + if tc.mode != "unset" { + mode = &tc.mode + } + signed := 0 + db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("iam-user")}, EnsureTLS(mode), nil, true, func(context.Context) (string, error) { signed++; return "fresh-token", nil }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var value int + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, &value) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("want %q, got %v", tc.want, err) + } + _ = listener.Close() + accepted := <-done + if tc.want == "TLS accepted" && !accepted { + t.Error("fresh password did not reach authentication packet") + } + if signed != 1 { + t.Errorf("expected one signing call, got %d", signed) + } + }) + } +} + +func writeMySQLTestPacket(w io.Writer, seq byte, body []byte) error { + header := []byte{byte(len(body)), byte(len(body) >> 8), byte(len(body) >> 16), seq} + _, err := w.Write(append(header, body...)) + return err +} +func readMySQLTestPacket(r io.Reader) ([]byte, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, err + } + n := int(header[0]) | int(header[1])<<8 | int(header[2])<<16 + body := make([]byte, n) + _, err := io.ReadFull(r, body) + return body, err +} diff --git a/pkg/clients/postgresql/postgresql.go b/pkg/clients/postgresql/postgresql.go index 9dd60287..0d18e6c2 100644 --- a/pkg/clients/postgresql/postgresql.go +++ b/pkg/clients/postgresql/postgresql.go @@ -3,8 +3,11 @@ package postgresql import ( "context" "database/sql" + "database/sql/driver" "errors" + "net" "net/url" + "strings" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/lib/pq" @@ -21,10 +24,11 @@ const ( ) type postgresDB struct { - dsn string - endpoint string - port string - sslmode string + dsn string + endpoint string + port string + sslmode string + passwordProvider xsql.PasswordProvider } // New returns a new PostgreSQL database client. The default database name is @@ -32,42 +36,110 @@ type postgresDB struct { // value of PGDATABASE, or if unset, the hardcoded string 'postgres'. // The sslmode defines the mode used to set up the connection for the provider. func New(creds map[string][]byte, database, sslmode string) xsql.DB { + return newPostgresDB(creds, database, sslmode, nil) +} + +// NewWithPasswordProvider configures credentials generated for each physical connection. +func NewWithPasswordProvider(creds map[string][]byte, database, sslmode string, password xsql.PasswordProvider) xsql.DB { + return newPostgresDB(creds, database, sslmode, password) +} + +func newPostgresDB(creds map[string][]byte, database, sslmode string, passwordProvider xsql.PasswordProvider) postgresDB { endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) password := string(creds[xpv2.CredentialsSecretPasswordKey]) - dsn := DSN(username, password, endpoint, port, database, sslmode) - + if passwordProvider != nil { + // Parse connection settings without retaining a password in the DSN. + password = "" + } return postgresDB{ - dsn: dsn, - endpoint: endpoint, - port: port, - sslmode: sslmode, + dsn: DSN(username, password, endpoint, port, database, connectionSSLMode(sslmode)), + endpoint: endpoint, port: port, sslmode: sslmode, passwordProvider: passwordProvider, + } +} + +func (c postgresDB) open() (*sql.DB, error) { + cfg, err := pq.NewConfig(c.dsn) + if err != nil { + return nil, withoutConnectionURL(err) + } + if strings.HasPrefix(c.sslmode, "pqgo-provider-sql-postgresql-") { + // Apply the selected CA after parsing environment defaults. In particular, + // PGSSLROOTCERT must not replace or supplement a ProviderConfig's roots. + cfg.SSLMode = pq.SSLMode(c.sslmode) + cfg.SSLRootCert = "" + } + if c.passwordProvider == nil { + connector, err := pq.NewConnectorConfig(cfg) + if err != nil { + return nil, err + } + return sql.OpenDB(connector), nil } + return sql.OpenDB(&xsql.PasswordConnector{ + Password: c.passwordProvider, + SQLDriver: &pq.Driver{}, + NewConnector: func(password string) (driver.Connector, error) { + next := cfg.Clone() + next.Password = password + return pq.NewConnectorConfig(next) + }, + }), nil +} + +func connectionSSLMode(mode string) string { + if strings.HasPrefix(mode, "pqgo-provider-sql-postgresql-") { + // Parse with a standard verifying mode before applying the registered + // CA config. This also accepts PGSSLROOTCERT=system without allowing that + // process-wide setting to override the selected Secret. + return "verify-full" + } + return mode +} + +// URL parser errors quote the complete DSN, including its password. +func withoutConnectionURL(err error) error { + var parseErr *url.Error + if errors.As(err, &parseErr) { + return parseErr.Err + } + return err +} + +// EnsureTLS requires encryption without weakening configured verification. +// Kubernetes defaults an omitted sslMode to verify-full; empty handles callers +// that construct ProviderConfigs without API-server defaulting. +func EnsureTLS(mode string) string { + if mode == "" || mode == "disable" || mode == "allow" || mode == "prefer" { + return "require" + } + return mode } // DSN returns the DSN URL func DSN(username, password, endpoint, port, database, sslmode string) string { - // Use net/url UserPassword to encode the username and password - // This will ensure that any special characters in the username or password - // are percent-encoded for use in the user info portion of the DSN URL - userInfo := url.UserPassword(username, password) - return "postgres://" + - userInfo.String() + "@" + - endpoint + ":" + - port + "/" + - database + - "?sslmode=" + sslmode + // Keep database names and credentials separate from connection options. + connection := url.URL{ + Scheme: "postgres", + User: url.UserPassword(username, password), + Host: net.JoinHostPort(endpoint, port), + Path: "/" + database, + RawQuery: url.Values{"sslmode": {sslmode}}.Encode(), + } + return connection.String() } // ExecTx executes an array of queries, committing if all are successful and // rolling back immediately on failure. func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return err } + defer d.Close() //nolint:errcheck + tx, err := d.BeginTx(ctx, nil) if err != nil { return err @@ -76,7 +148,6 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Rollback or Commit based on error state. Defer close in defer to make // sure the connection is always closed. defer func() { - defer d.Close() //nolint:errcheck // We always rollback, it's a no-op if the tx was already committed. defer tx.Rollback() //nolint:errcheck @@ -86,7 +157,7 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { }() for _, q := range ql { - if _, err = tx.Exec(q.String, q.Parameters...); err != nil { + if _, err = tx.ExecContext(ctx, q.String, q.Parameters...); err != nil { return err } } @@ -95,7 +166,7 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Exec the supplied query. func (c postgresDB) Exec(ctx context.Context, q xsql.Query) error { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return err } @@ -107,7 +178,7 @@ func (c postgresDB) Exec(ctx context.Context, q xsql.Query) error { // Query the supplied query. func (c postgresDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return nil, err } @@ -119,7 +190,7 @@ func (c postgresDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) // Scan the results of the supplied query into the supplied destination. func (c postgresDB) Scan(ctx context.Context, q xsql.Query, dest ...interface{}) error { - db, err := sql.Open("postgres", c.dsn) + db, err := c.open() if err != nil { return err } @@ -141,7 +212,7 @@ func (c postgresDB) GetConnectionDetails(username, password string) managed.Conn // GetServerVersion returns the PostgreSQL server version as an integer // For example, PostgreSQL 16.2 would return 160200. func (c postgresDB) GetServerVersion(ctx context.Context) (int, error) { - db, err := sql.Open("postgres", c.dsn) + db, err := c.open() if err != nil { return 0, err } diff --git a/pkg/clients/postgresql/postgresql_test.go b/pkg/clients/postgresql/postgresql_test.go index a2056b91..11a56d4f 100644 --- a/pkg/clients/postgresql/postgresql_test.go +++ b/pkg/clients/postgresql/postgresql_test.go @@ -1,7 +1,15 @@ package postgresql import ( + "context" + "os" + "strings" "testing" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) func TestDSNURLEscaping(t *testing.T) { @@ -17,3 +25,124 @@ func TestDSNURLEscaping(t *testing.T) { t.Errorf("DSN string did not match expected output with userinfo URL encoded") } } + +func TestPasswordProviderRejectsMalformedConfigBeforeSigning(t *testing.T) { + const token = "SYNTHETIC-IAM-TOKEN" + for _, tc := range []struct { + name, endpoint, port, database string + }{ + {"host control character", "db.example.com\n", "5432", "postgres"}, + {"invalid port", "db.example.com", "invalid", "postgres"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + db := NewWithPasswordProvider(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte(tc.endpoint), + xpv2.CredentialsSecretPortKey: []byte(tc.port), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte("IGNORED-STATIC-PASSWORD"), + }, tc.database, "verify-full", func(context.Context) (string, error) { + calls++ + return token, nil + }) + err := db.Scan(context.Background(), xsql.Query{String: "SELECT 1"}, new(int)) + if err == nil { + t.Fatal("malformed connection settings must fail") + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "IGNORED-STATIC-PASSWORD") { + t.Fatalf("connection error exposed a credential: %v", err) + } + if calls != 0 { + t.Fatalf("signed %d tokens before rejecting malformed settings", calls) + } + }) + } +} + +func TestDSNDatabaseCannotOverrideConnectionSettings(t *testing.T) { + for _, database := range []string{ + "sales%zz", "sales#archive", "sales\n", "sales/2026", + "postgres?hostaddr=203.0.113.10&sslmode=disable&x=", + } { + t.Run(database, func(t *testing.T) { + cfg, err := pq.NewConfig(DSN("user", "password", "db.example.com", "5432", database, "verify-full")) + if err != nil { + t.Fatal(err) + } + if cfg.Host != "db.example.com" || cfg.Hostaddr.IsValid() || cfg.Database != database || cfg.SSLMode != "verify-full" { + t.Fatalf("database name changed connection settings: host=%q hostaddr=%v database=%q sslmode=%q", cfg.Host, cfg.Hostaddr, cfg.Database, cfg.SSLMode) + } + }) + } +} + +func TestStaticPasswordNotInConfigError(t *testing.T) { + const password = "SYNTHETIC-STATIC-PASSWORD" + db := New(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com\n"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte(password), + }, "postgres", "verify-full") + err := db.Scan(context.Background(), xsql.Query{String: "SELECT 1"}, new(int)) + if err == nil || strings.Contains(err.Error(), password) { + t.Fatalf("expected a connection error without the password, got %v", err) + } +} + +func TestEnsureTLS(t *testing.T) { + for _, tc := range []struct{ mode, want string }{ + {"", "require"}, {"disable", "require"}, {"require", "require"}, + {"allow", "require"}, {"prefer", "require"}, + {"verify-ca", "verify-ca"}, {"verify-full", "verify-full"}, {"invalid", "invalid"}, + } { + t.Run(tc.mode, func(t *testing.T) { + if got := EnsureTLS(tc.mode); got != tc.want { + t.Errorf("want %q, got %q", tc.want, got) + } + }) + } +} + +func TestPasswordProviderConstructorDoesNotRetainStoredPassword(t *testing.T) { + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "PG") { + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } + } + const stored = "STORED-PASSWORD-MUST-NOT-BE-RETAINED" + creds := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte(stored), + } + calls := 0 + password := func(context.Context) (string, error) { calls++; return "fresh-token", nil } + dynamic := newPostgresDB(creds, "postgres", "verify-full", password) + if strings.Contains(dynamic.dsn, stored) { + t.Fatal("dynamic connection retained the stored password in its DSN") + } + cfg, err := pq.NewConfig(dynamic.dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Password != "" { + t.Fatal("dynamic connection parsed a retained password") + } + if calls != 0 || dynamic.passwordProvider == nil { + t.Fatal("constructor changed when passwords are generated") + } + static := newPostgresDB(creds, "postgres", "verify-full", nil) + cfg, err = pq.NewConfig(static.dsn) + if err != nil || cfg.Password != stored { + t.Fatalf("static password behavior changed: %v", err) + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != stored { + t.Fatal("constructor modified the caller's credentials") + } +} diff --git a/pkg/clients/postgresql/tls.go b/pkg/clients/postgresql/tls.go new file mode 100644 index 00000000..8f23a0d1 --- /dev/null +++ b/pkg/clients/postgresql/tls.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postgresql + +import ( + "crypto/tls" + "crypto/x509" + "fmt" +) + +// TLSConfig builds server verification using only the selected CA bundle. +// verify-full checks both the certificate chain and the endpoint hostname. +// verify-ca checks the chain without checking the hostname. +func TLSConfig(mode, endpoint string, ca []byte) (*tls.Config, error) { + if mode != "verify-ca" && mode != "verify-full" { + return nil, fmt.Errorf("tlsConfig.caCert requires sslMode verify-ca or verify-full, got %q", mode) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("tlsConfig.caCert does not contain a valid PEM certificate") + } + cfg := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: endpoint, RootCAs: roots} + if mode == "verify-ca" { + cfg.InsecureSkipVerify = true //nolint:gosec // The callback verifies the chain; this mode deliberately omits hostname verification. + cfg.VerifyConnection = func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("PostgreSQL server presented no certificate") + } + intermediates := x509.NewCertPool() + for _, cert := range state.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + _, err := state.PeerCertificates[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates}) + return err + } + } + return cfg, nil +} diff --git a/pkg/clients/postgresql/tls_test.go b/pkg/clients/postgresql/tls_test.go new file mode 100644 index 00000000..e0b3b3cb --- /dev/null +++ b/pkg/clients/postgresql/tls_test.go @@ -0,0 +1,305 @@ +package postgresql + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "encoding/pem" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" +) + +func TestIAMRequiresTLSWhenServerRefusesIt(t *testing.T) { + for _, mode := range []string{"", "disable", "allow", "prefer"} { + t.Run(mode, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan bool, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- false + return + } + defer conn.Close() + _ = listener.Close() // Any plaintext retry must fail too. + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + request := make([]byte, 8) + _, err = io.ReadFull(conn, request) + isSSL := err == nil && binary.BigEndian.Uint32(request[4:]) == 80877103 + if isSSL { + _, _ = conn.Write([]byte("N")) + } + done <- isSSL + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + db := NewWithPasswordProvider(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte(host), + xpv2.CredentialsSecretPortKey: []byte(port), + xpv2.CredentialsSecretUserKey: []byte("user"), + }, "postgres", EnsureTLS(mode), func(context.Context) (string, error) { return "fresh-token", nil }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, new(int)) + _ = listener.Close() + if !<-done { + t.Error("driver did not request TLS before authenticating") + } + if err == nil || !strings.Contains(err.Error(), "SSL is not enabled on the server") { + t.Fatalf("expected refusal of plaintext without fallback, got %v", err) + } + }) + } +} + +// Isolate Go's cached system roots so this test exercises the documented +// SSL_CERT_FILE mount mechanism without affecting other tests in the package. +func TestTLSSystemRoots(t *testing.T) { + if os.Getenv("PROVIDER_SQL_TLS_TEST_CHILD") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestTLSSystemRoots$", "-test.v", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_TLS_TEST_CHILD=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("TLS subprocess: %v\n%s", err, out) + } + return + } + dir := t.TempDir() + t.Setenv("SSL_CERT_FILE", filepath.Join(dir, "ca-bundle.pem")) + t.Setenv("SSL_CERT_DIR", dir) + t.Setenv("PGSSLROOTCERT", "") + t.Setenv("PGSSLCERT", "") + t.Setenv("PGSSLKEY", "") + t.Setenv("PGSERVICE", "") + t.Setenv("HOME", dir) + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("SSL_CERT_FILE"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { + t.Fatal(err) + } + secretKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + secretCA := *ca + secretCA.SerialNumber = big.NewInt(10) + secretCA.Subject = pkix.Name{CommonName: "separate Secret CA"} + secretDER, err := x509.CreateCertificate(rand.Reader, &secretCA, &secretCA, &secretKey.PublicKey, secretKey) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, mode string + trusted, matching bool + want string + rootFile, secretCA, systemRoots, systemServer bool + homeRoot, invalidHomeRoot bool + }{ + {name: "trusted matching", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted"}, + {name: "untrusted", mode: "verify-full", matching: true, want: "unknown authority"}, + {name: "wrong hostname", mode: "verify-full", trusted: true, want: "not 127.0.0.1"}, + {name: "CA only permits hostname mismatch", mode: "verify-ca", trusted: true, want: "TLS accepted"}, + {name: "require ignores system roots", mode: "require", want: "TLS accepted"}, + {name: "verify-ca rejects untrusted", mode: "verify-ca", matching: true, want: "unknown authority"}, + {name: "require with root file verifies CA", mode: "require", matching: true, want: "unknown authority", rootFile: true}, + {name: "require with root file skips hostname", mode: "require", trusted: true, want: "TLS accepted", rootFile: true}, + {name: "secret CA verifies server absent from system pool", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true}, + {name: "secret CA overrides system roots", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, systemRoots: true}, + {name: "secret CA rejects system-trusted server with explicit system roots", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemRoots: true, systemServer: true}, + {name: "secret CA rejects system-trusted server", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemServer: true}, + {name: "secret CA ignores unrelated default root file", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, homeRoot: true}, + {name: "secret CA ignores malformed default root file", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, homeRoot: true, invalidHomeRoot: true}, + {name: "secret CA rejects server trusted only by default root file", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, homeRoot: true, systemServer: true}, + {name: "secret CA rejects unknown root", mode: "verify-full", matching: true, want: "unknown authority", secretCA: true}, + {name: "secret CA rejects wrong hostname", mode: "verify-full", trusted: true, want: "not 127.0.0.1", secretCA: true}, + {name: "secret verify-ca permits different hostname", mode: "verify-ca", trusted: true, want: "TLS accepted", secretCA: true}, + {name: "secret verify-ca rejects unknown root", mode: "verify-ca", matching: true, want: "unknown authority", secretCA: true}, + {name: "secret verify-ca rejects system-trusted server", mode: "verify-ca", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemServer: true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.homeRoot { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.Mkdir(filepath.Join(home, ".postgresql"), 0700); err != nil { + t.Fatal(err) + } + root := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + if tc.invalidHomeRoot { + root = []byte("invalid PEM") + } + if err := os.WriteFile(filepath.Join(home, ".postgresql", "root.crt"), root, 0600); err != nil { + t.Fatal(err) + } + } + if tc.rootFile { + t.Setenv("PGSSLROOTCERT", os.Getenv("SSL_CERT_FILE")) + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ip := "127.0.0.1" + if !tc.matching { + ip = "127.0.0.2" + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP(ip)}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature} + parent, signer := ca, caKey + if tc.secretCA && tc.trusted && !tc.systemServer { + parent, signer = &secretCA, secretKey + } + if !tc.trusted { + parent, signer = leaf, key + } + der, err := x509.CreateCertificate(rand.Reader, leaf, parent, &key.PublicKey, signer) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + passwords := make(chan string, 1) + const token = "db.example.com:5432/?Action=connect&DBUser=test&X-Amz-Signature=a%2Fb+c" + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + request := make([]byte, 8) + if _, err = io.ReadFull(conn, request); err != nil { + return + } + if binary.BigEndian.Uint32(request[4:]) != 80877103 { + return + } + if _, err = conn.Write([]byte("S")); err != nil { + return + } + secure := tls.Server(conn, &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}) + if err = secure.Handshake(); err != nil { + return + } + // Wait for the client's startup message. verify-ca performs its manual + // certificate check after the TLS handshake and before sending startup. + length := make([]byte, 4) + if _, err = io.ReadFull(secure, length); err != nil { + return + } + size := binary.BigEndian.Uint32(length) + if size < 4 || size > 65536 { + return + } + if _, err = io.CopyN(io.Discard, secure, int64(size-4)); err != nil { + return + } + // RDS PostgreSQL requests the IAM token as a cleartext password inside TLS. + if _, err = secure.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 3}); err != nil { + return + } + header := make([]byte, 5) + if _, err = io.ReadFull(secure, header); err != nil || header[0] != 'p' { + return + } + size = binary.BigEndian.Uint32(header[1:]) + if size < 5 || size > 65536 { + return + } + password := make([]byte, size-4) + if _, err = io.ReadFull(secure, password); err != nil { + return + } + passwords <- string(password) + // A PostgreSQL startup error proves TLS verification completed. No real + // PostgreSQL server or database credentials are needed. + body := []byte("SFATAL\x00C28000\x00MTLS accepted\x00\x00") + message := make([]byte, 5) + message[0] = 'E' + binary.BigEndian.PutUint32(message[1:], uint32(len(body)+4)) + _, _ = secure.Write(append(message, body...)) + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + mode := EnsureTLS(tc.mode) + if tc.secretCA { + // A per-config CA must win over process-wide root settings. + t.Setenv("PGSSLROOTCERT", filepath.Join(dir, "does-not-exist.pem")) + if tc.systemRoots { + t.Setenv("PGSSLROOTCERT", "system") + } + if tc.homeRoot { + t.Setenv("PGSSLROOTCERT", "") + } + cfg, err := TLSConfig(tc.mode, host, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: secretDER})) + if err != nil { + t.Fatal(err) + } + key := "provider-sql-postgresql-" + strings.ReplaceAll(tc.name, " ", "-") + if err := pq.RegisterTLSConfig(key, cfg); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(key, nil) }) + mode = "pqgo-" + key + } + signed := 0 + db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", mode, func(context.Context) (string, error) { signed++; return token, nil }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var value int + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, &value) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("want error containing %q, got %v", tc.want, err) + } + if signed != 1 { + t.Errorf("expected a freshly generated password, got %d signing calls", signed) + } + _ = listener.Close() + <-done + if tc.want == "TLS accepted" { + select { + case got := <-passwords: + if got != token+"\x00" { + t.Errorf("driver altered the token: got %q", got) + } + default: + t.Error("server did not receive the generated token") + } + } else if len(passwords) != 0 { + t.Error("driver sent the token before verifying the server") + } + }) + } +} diff --git a/pkg/clients/xsql/password.go b/pkg/clients/xsql/password.go new file mode 100644 index 00000000..589a815f --- /dev/null +++ b/pkg/clients/xsql/password.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xsql + +import ( + "context" + "database/sql/driver" +) + +// PasswordProvider returns credentials for a new physical database connection. +// Implementations must support concurrent calls and respect cancellation. +type PasswordProvider func(context.Context) (string, error) + +// PasswordConnector obtains a fresh password on every physical connection, +// including connections database/sql opens when retrying a bad connection. +type PasswordConnector struct { + Password PasswordProvider + NewConnector func(string) (driver.Connector, error) + SQLDriver driver.Driver +} + +func (c *PasswordConnector) Connect(ctx context.Context) (driver.Conn, error) { + password, err := c.Password(ctx) + if err != nil { + return nil, err + } + connector, err := c.NewConnector(password) + if err != nil { + return nil, err + } + return connector.Connect(ctx) +} + +func (c *PasswordConnector) Driver() driver.Driver { return c.SQLDriver } diff --git a/pkg/clients/xsql/password_test.go b/pkg/clients/xsql/password_test.go new file mode 100644 index 00000000..476ad507 --- /dev/null +++ b/pkg/clients/xsql/password_test.go @@ -0,0 +1,166 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xsql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + "testing" + "time" +) + +type tokenTestDriver struct{} + +func (tokenTestDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("unexpected Driver.Open: bypassed password connector") +} + +type tokenTestConnector struct { + connect func(context.Context) (driver.Conn, error) +} + +func (c tokenTestConnector) Driver() driver.Driver { return tokenTestDriver{} } +func (c tokenTestConnector) Connect(ctx context.Context) (driver.Conn, error) { return c.connect(ctx) } + +type tokenTestConn struct{ bad *atomic.Bool } + +func (tokenTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (tokenTestConn) Close() error { return nil } +func (tokenTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") } +func (c tokenTestConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + if c.bad != nil && c.bad.Swap(false) { + return nil, driver.ErrBadConn + } + return &tokenTestRows{}, nil +} + +type tokenTestRows struct{ read bool } + +func (*tokenTestRows) Columns() []string { return []string{"value"} } +func (*tokenTestRows) Close() error { return nil } +func (r *tokenTestRows) Next(values []driver.Value) error { + if r.read { + return io.EOF + } + values[0] = int64(1) + r.read = true + return nil +} + +func TestPasswordConnectorRefreshesAfterExpiryAndBadConnection(t *testing.T) { + now := time.Unix(0, 0) + var tokens []string + var retry atomic.Bool + connector := &PasswordConnector{ + Password: func(context.Context) (string, error) { return fmt.Sprintf("signed-at-%d", now.Unix()), nil }, + SQLDriver: tokenTestDriver{}, + NewConnector: func(password string) (driver.Connector, error) { + want := fmt.Sprintf("signed-at-%d", now.Unix()) + if password != want { + return nil, errors.New("expired token") + } + tokens = append(tokens, password) + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return tokenTestConn{bad: &retry}, nil }}, nil + }, + } + db := sql.OpenDB(connector) + defer db.Close() + query := func() { + t.Helper() + var value int + if err := db.QueryRowContext(context.Background(), "SELECT 1").Scan(&value); err != nil { + t.Fatal(err) + } + } + query() + now = now.Add(20 * time.Minute) + // Existing authenticated sessions survive token expiry; a broken session + // makes database/sql retry with a new physical connection and a fresh token. + retry.Store(true) + query() + if len(tokens) != 2 || tokens[0] == tokens[1] { + t.Fatalf("expected fresh token after reconnect, got %v", tokens) + } + db.SetMaxIdleConns(0) + now = now.Add(20 * time.Minute) + query() + if len(tokens) != 3 || tokens[1] == tokens[2] { + t.Fatal("later physical connection reused an old token") + } +} + +func TestPasswordConnectorErrorsAndCancellation(t *testing.T) { + boom := errors.New("signing failed") + c := &PasswordConnector{Password: func(context.Context) (string, error) { return "", boom }, SQLDriver: tokenTestDriver{}, NewConnector: func(string) (driver.Connector, error) { + t.Fatal("must not connect after signing error") + return nil, nil + }} + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost signing error: %v", err) + } + c.Password = func(ctx context.Context) (string, error) { return "", ctx.Err() } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Connect(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("lost cancellation: %v", err) + } + c.Password = func(context.Context) (string, error) { return "token", nil } + c.NewConnector = func(string) (driver.Connector, error) { return nil, boom } + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost connector error: %v", err) + } + c.NewConnector = func(string) (driver.Connector, error) { + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return nil, boom }}, nil + } + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost connection error: %v", err) + } +} + +func TestPasswordConnectorConcurrentConnections(t *testing.T) { + var issued atomic.Int64 + var seen sync.Map + c := &PasswordConnector{Password: func(context.Context) (string, error) { return fmt.Sprint(issued.Add(1)), nil }, SQLDriver: tokenTestDriver{}, NewConnector: func(password string) (driver.Connector, error) { + if _, loaded := seen.LoadOrStore(password, true); loaded { + return nil, errors.New("reused token") + } + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return tokenTestConn{}, nil }}, nil + }} + var workers sync.WaitGroup + for range 20 { + workers.Go(func() { + conn, err := c.Connect(context.Background()) + if err != nil { + t.Error(err) + return + } + _ = conn.Close() + }) + } + workers.Wait() + if issued.Load() != 20 { + t.Fatalf("want 20 independently generated tokens, got %d", issued.Load()) + } +} diff --git a/pkg/controller/cluster/mysql/connect/connect.go b/pkg/controller/cluster/mysql/connect/connect.go new file mode 100644 index 00000000..fde6544a --- /dev/null +++ b/pkg/controller/cluster/mysql/connect/connect.go @@ -0,0 +1,50 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package connect prepares MySQL connections for cluster reconcilers. +package connect + +import ( + "context" + "fmt" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var newIAMToken = awsiam.NewTokenProvider + +// Configure preserves password-auth defaults and prepares IAM signing and CA trust. +func Configure(ctx context.Context, kube client.Client, pc *v1alpha1.ProviderConfig, secretData map[string][]byte) (*string, bool, xsql.PasswordProvider, error) { + mode, err := tls.LoadConfig(ctx, kube, pc.Name, pc.Spec.TLS, pc.Spec.TLSConfig) + if err != nil { + return nil, false, nil, fmt.Errorf("cannot load TLS config: %w", err) + } + var password xsql.PasswordProvider + iam := pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth + if iam { + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { + return nil, false, nil, fmt.Errorf("cannot generate AWS IAM authentication token: %w", err) + } + mode = mysql.EnsureTLS(mode) + } + return mode, iam, password, nil +} diff --git a/pkg/controller/cluster/mysql/connect/connect_test.go b/pkg/controller/cluster/mysql/connect/connect_test.go new file mode 100644 index 00000000..7e16699d --- /dev/null +++ b/pkg/controller/cluster/mysql/connect/connect_test.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package connect + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + driver "github.com/go-sql-driver/mysql" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestConfigureModesAndAuthentication(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + calls := 0 + newIAMToken = func(_ context.Context, region *string, _ map[string][]byte) (xsql.PasswordProvider, error) { + calls++ + if region == nil || *region != "eu-central-1" { + t.Fatal("region was not passed through") + } + tokens := 0 + return func(context.Context) (string, error) { tokens++; return fmt.Sprintf("token-%d", tokens), nil }, nil + } + for _, iam := range []bool{false, true} { + for _, tc := range []struct{ mode, iamMode string }{{"unset", "true"}, {"", "true"}, {"false", "true"}, {"preferred", "true"}, {"true", "true"}, {"skip-verify", "skip-verify"}} { + t.Run(fmt.Sprintf("IAM=%t/%s", iam, tc.mode), func(t *testing.T) { + calls = 0 + region := "eu-central-1" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: strings.ReplaceAll(t.Name(), "/", "-")}} + pc.Spec.Credentials.Region = ®ion + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + if iam { + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + } + if tc.mode != "unset" { + pc.Spec.TLS = &tc.mode + } + creds := map[string][]byte{xpv2.CredentialsSecretPasswordKey: []byte("stored-password")} + mode, cleartext, password, err := Configure(context.Background(), nil, pc, creds) + if err != nil { + t.Fatal(err) + } + want := tc.mode + if want == "unset" { + want = "" + } + if iam { + want = tc.iamMode + } + got := "" + if mode != nil { + got = *mode + } + if cleartext != iam { + t.Fatal("cleartext flag does not match IAM") + } + if !iam && tc.mode == "unset" && mode != nil { + t.Fatal("password auth changed the nil TLS mode") + } + if got != want { + t.Fatalf("mode: got %q, want %q", got, want) + } + if iam { + if calls != 1 || password == nil { + t.Fatal("IAM factory was not used exactly once") + } + for i := 1; i <= 2; i++ { + token, err := password(context.Background()) + if err != nil || token != fmt.Sprintf("token-%d", i) { + t.Fatalf("fresh password: %q, %v", token, err) + } + } + } else if calls != 0 || password != nil { + t.Fatal("password authentication invoked IAM signing") + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != "stored-password" { + t.Fatal("configuration rewrote stored credentials") + } + }) + } + } +} + +func TestConfigureIAMError(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + boom := errors.New("signer unavailable") + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { return nil, boom } + pc := &v1alpha1.ProviderConfig{} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + _, _, _, err := Configure(context.Background(), nil, pc, nil) + if !errors.Is(err, boom) || !strings.Contains(err.Error(), "cannot generate AWS IAM authentication token") { + t.Fatalf("IAM error: %v", err) + } +} + +func testBundle(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestConfigureCAOnlyAndTLSFailure(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "token", nil }, nil + } + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testBundle(t)}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + mode := "custom" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "helper-ca"}} + pc.Spec.TLS = &mode + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + pc.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + name, cleartext, password, err := Configure(context.Background(), kube, pc, nil) + if err != nil || name == nil || !cleartext || password == nil { + t.Fatalf("CA-only IAM config: %v", err) + } + t.Cleanup(func() { driver.DeregisterTLSConfig(*name) }) + next, _, _, err := Configure(context.Background(), kube, pc, nil) + if err != nil || *next != *name { + t.Fatalf("registration changed on reload: %v", err) + } + secret.Data["bundle"] = []byte("not a certificate") + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + if _, _, _, err := Configure(context.Background(), kube, pc, nil); err == nil || !strings.Contains(err.Error(), "cannot load TLS config") { + t.Fatalf("TLS error: %v", err) + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index 5d353b3b..15eddf5a 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -38,7 +38,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -46,7 +46,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" errSelectDB = "cannot select database" errCreateDB = "cannot create database" @@ -63,7 +62,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,7 +93,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Database] = &connector{} @@ -112,9 +111,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -125,13 +122,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/mysql/database/reconciler_test.go b/pkg/controller/cluster/mysql/database/reconciler_test.go index 4df7cf65..3668780f 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -19,6 +19,8 @@ package database import ( "context" "database/sql" + "os" + "strings" "testing" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" @@ -66,10 +68,26 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Keep this real-signing test independent of the developer's AWS configuration. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "AWS_") { + t.Setenv(key, "") + } + } + for key, value := range map[string]string{ + "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", + "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true", + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + } { + t.Setenv(key, value) + } + type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -161,11 +179,64 @@ func TestConnect(t *testing.T) { }, want: errors.Wrap(errBoom, errGetSecret), }, + "SuccessAWSIAMAuth": { + reason: "When source is AWSIAMAuth, a token is injected as the password, cleartext is enabled and TLS is forced.", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + o.Spec.Credentials.ConnectionSecretRef = &xpv2.SecretReference{Namespace: "ns", Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + }, + track: nopUsage, + newDB: func(creds map[string][]byte, tls *string, _ *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB { + if !cleartext { + t.Error("expected cleartext=true for AWS IAM auth") + } + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { + t.Errorf("token: %q, %v", got, err) + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be cached in credentials") + } + if tls == nil || *tls != "true" { + t.Errorf("expected verified TLS by default, got %v", tls) + } + return mockDB{} + }, + }, + args: args{ + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{}, + }, + }, + }, + }, + want: nil, + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &connector{kube: tc.fields.kube, track: tc.fields.track, newDB: tc.fields.newDB} + if tc.args.ctx == nil { + tc.args.ctx = context.Background() + } _, err := e.Connect(tc.args.ctx, tc.args.mg) if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) diff --git a/pkg/controller/cluster/mysql/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index 9c358aa4..c559d6ad 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -42,7 +42,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -50,7 +50,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" errCreateGrant = "cannot create grant" errRevokeGrant = "cannot revoke grant" @@ -71,7 +70,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithReferenceResolver(managed.NewAPISimpleReferenceResolver(mgr.GetClient())), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), @@ -102,7 +101,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Grant] = &connector{} @@ -120,9 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -133,13 +130,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/mysql/grant/reconciler_test.go b/pkg/controller/cluster/mysql/grant/reconciler_test.go index ccddbcda..1d0610f9 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler_test.go +++ b/pkg/controller/cluster/mysql/grant/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/mysql/tls/tls.go b/pkg/controller/cluster/mysql/tls/tls.go index 56d46060..e028d176 100644 --- a/pkg/controller/cluster/mysql/tls/tls.go +++ b/pkg/controller/cluster/mysql/tls/tls.go @@ -2,6 +2,7 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "fmt" @@ -29,7 +30,7 @@ func LoadConfig(ctx context.Context, kube client.Client, providerConfigName stri return nil, err } - tlsName := fmt.Sprintf("custom-%s", providerConfigName) + tlsName := fmt.Sprintf("custom-cluster-%x", sha256.Sum256([]byte(providerConfigName))) err := registerTLS(ctx, kube, tlsName, cfg) if err != nil { return nil, err @@ -38,14 +39,14 @@ func LoadConfig(ctx context.Context, kube client.Client, providerConfigName stri } func validateTLSConfig(cfg *v1alpha1.TLSConfig) error { - if cfg == nil || - cfg.CACert.SecretRef.Name == "" || - cfg.CACert.SecretRef.Key == "" || - cfg.ClientCert.SecretRef.Name == "" || - cfg.ClientCert.SecretRef.Key == "" || - cfg.ClientKey.SecretRef.Name == "" || - cfg.ClientKey.SecretRef.Key == "" { - return fmt.Errorf("tlsConfig is required when tls=custom") + if cfg == nil || cfg.CACert.SecretRef.Name == "" || cfg.CACert.SecretRef.Key == "" { + return fmt.Errorf("tlsConfig.caCert is required when tls=custom") + } + cert, key := cfg.ClientCert.SecretRef, cfg.ClientKey.SecretRef + hasCert := cert != (xpv2.SecretKeySelector{}) + hasKey := key != (xpv2.SecretKeySelector{}) + if hasCert != hasKey || (hasCert && (cert.Name == "" || cert.Key == "" || key.Name == "" || key.Key == "")) { + return fmt.Errorf("tlsConfig.clientCert and clientKey must be provided together with complete secret references") } return nil } @@ -65,16 +66,19 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *v return fmt.Errorf("cannot append CA certificate to pool") } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err - } - - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + config := &tls.Config{ + MinVersion: tls.VersionTLS12, RootCAs: pool, - Certificates: []tls.Certificate{keyPair}, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // This is only required by integration tests and should never be used in production - }) + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // Explicit operator setting retained for compatibility. + } + if cfg.ClientCert.SecretRef.Name != "" { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + config.Certificates = []tls.Certificate{keyPair} + } + return mysql.RegisterTLSConfig(tlsName, config) } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *v1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 93b46d2f..c80a391a 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -41,7 +41,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -49,7 +49,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" errSelectUser = "cannot select user" errCreateUser = "cannot create user" @@ -67,7 +66,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -97,7 +96,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.User] = &connector{} @@ -115,9 +114,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -128,14 +125,14 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) return &external{ - db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password), kube: c.kube, }, nil } diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index b6baca70..bc050a85 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/connect/connect.go b/pkg/controller/cluster/postgresql/connect/connect.go new file mode 100644 index 00000000..25a06afb --- /dev/null +++ b/pkg/controller/cluster/postgresql/connect/connect.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package connect prepares PostgreSQL connections for cluster reconcilers. +package connect + +import ( + "context" + "fmt" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var newIAMToken = awsiam.NewTokenProvider + +// Configure preserves password-auth defaults and prepares IAM signing and CA trust. +func Configure(ctx context.Context, kube client.Client, pc *v1alpha1.ProviderConfig, secretData map[string][]byte) (string, xsql.PasswordProvider, error) { + mode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { + return "", nil, fmt.Errorf("cannot generate AWS IAM authentication token: %w", err) + } + mode = postgresql.EnsureTLS(mode) + } + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + mode, err := postgresqltls.LoadConfig(ctx, kube, "cluster/ProviderConfig/"+pc.Name, mode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return "", nil, fmt.Errorf("cannot configure PostgreSQL TLS: %w", err) + } + return mode, password, nil +} diff --git a/pkg/controller/cluster/postgresql/connect/connect_test.go b/pkg/controller/cluster/postgresql/connect/connect_test.go new file mode 100644 index 00000000..d67d79dc --- /dev/null +++ b/pkg/controller/cluster/postgresql/connect/connect_test.go @@ -0,0 +1,171 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package connect + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestConfigureModesAndAuthentication(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + calls := 0 + newIAMToken = func(_ context.Context, region *string, _ map[string][]byte) (xsql.PasswordProvider, error) { + calls++ + if region == nil || *region != "eu-central-1" { + t.Fatal("region was not passed through") + } + tokens := 0 + return func(context.Context) (string, error) { tokens++; return fmt.Sprintf("token-%d", tokens), nil }, nil + } + for _, iam := range []bool{false, true} { + for _, tc := range []struct{ mode, iamMode string }{{"unset", "require"}, {"", "require"}, {"disable", "require"}, {"allow", "require"}, {"prefer", "require"}, {"require", "require"}, {"verify-ca", "verify-ca"}, {"verify-full", "verify-full"}} { + t.Run(fmt.Sprintf("IAM=%t/%s", iam, tc.mode), func(t *testing.T) { + calls = 0 + region := "eu-central-1" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: strings.ReplaceAll(t.Name(), "/", "-")}} + pc.Spec.Credentials.Region = ®ion + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + } + if tc.mode != "unset" { + pc.Spec.SSLMode = &tc.mode + } + creds := map[string][]byte{xpv2.CredentialsSecretPasswordKey: []byte("stored-password")} + mode, password, err := Configure(context.Background(), nil, pc, creds) + if err != nil { + t.Fatal(err) + } + want := tc.mode + if want == "unset" { + want = "" + } + if iam { + want = tc.iamMode + } + got := mode + if got != want { + t.Fatalf("mode: got %q, want %q", got, want) + } + if iam { + if calls != 1 || password == nil { + t.Fatal("IAM factory was not used exactly once") + } + for i := 1; i <= 2; i++ { + token, err := password(context.Background()) + if err != nil || token != fmt.Sprintf("token-%d", i) { + t.Fatalf("fresh password: %q, %v", token, err) + } + } + } else if calls != 0 || password != nil { + t.Fatal("password authentication invoked IAM signing") + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != "stored-password" { + t.Fatal("configuration rewrote stored credentials") + } + }) + } + } +} + +func TestConfigureIAMError(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + boom := errors.New("signer unavailable") + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { return nil, boom } + pc := &v1alpha1.ProviderConfig{} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + _, _, err := Configure(context.Background(), nil, pc, nil) + if !errors.Is(err, boom) || !strings.Contains(err.Error(), "cannot generate AWS IAM authentication token") { + t.Fatalf("IAM error: %v", err) + } +} + +func testBundle(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestConfigureCAReplacementAndValidation(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testBundle(t)}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "helper-ca"}} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + pc.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + creds := map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte("db.example")} + first, password, err := Configure(context.Background(), kube, pc, creds) + if err != nil || password != nil || !strings.HasPrefix(first, "pqgo-provider-sql-postgresql-") { + t.Fatalf("CA-only config: %q %v", first, err) + } + t.Cleanup(func() { + _, _ = postgresqltls.LoadConfig(context.Background(), nil, "cluster/ProviderConfig/helper-ca", "verify-full", "db.example", nil) + }) + secret.Data["bundle"] = []byte("not a certificate") + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + if _, _, err := Configure(context.Background(), kube, pc, creds); err == nil { + t.Fatal("invalid CA was accepted") + } + secret.Data["bundle"] = testBundle(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + next, _, err := Configure(context.Background(), kube, pc, creds) + if err != nil || next != first { + t.Fatalf("CA replacement changed identity: %q %v", next, err) + } + require := "require" + pc.Spec.SSLMode = &require + if _, _, err := Configure(context.Background(), kube, pc, creds); err == nil { + t.Fatal("CA with require mode was accepted") + } +} diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 362d7de7..0f845e36 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -41,9 +41,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -71,7 +71,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -101,7 +101,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Database] = &connector{} @@ -118,9 +118,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -132,7 +130,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, clients.ToString(pc.Spec.SSLMode))}, nil + + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 1b761dc3..6858b66b 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -19,6 +19,8 @@ package database import ( "context" "database/sql" + "os" + "strings" "testing" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" @@ -73,10 +75,26 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Keep this real-signing test independent of the developer's AWS configuration. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "AWS_") { + t.Setenv(key, "") + } + } + for key, value := range map[string]string{ + "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", + "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true", + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + } { + t.Setenv(key, value) + } + type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -168,11 +186,93 @@ func TestConnect(t *testing.T) { }, want: errors.Wrap(errBoom, errGetSecret), }, + "SuccessAWSIAMAuth": { + reason: "When source is AWSIAMAuth, a token is injected as the password and an unset sslmode requires encryption.", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + o.Spec.Credentials.ConnectionSecretRef = &xpv2.SecretReference{Namespace: "ns", Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + }, + track: nopUsage, + newDB: func(creds map[string][]byte, _ string, sslmode string, password xsql.PasswordProvider) xsql.DB { + if sslmode != "require" { + t.Errorf("expected sslmode forced to require, got %q", sslmode) + } + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { + t.Errorf("token: %q, %v", got, err) + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be cached in credentials") + } + return mockDB{} + }, + }, + args: args{ + ctx: context.Background(), + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{}, + }, + }, + }, + }, + want: nil, + }, + } + + for _, mode := range []string{"", "disable", "require", "verify-ca", "verify-full"} { + tc := cases["SuccessAWSIAMAuth"] + baseGet := tc.fields.kube.(*test.MockClient).MockGet + tc.fields.kube = &test.MockClient{MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if err := baseGet(ctx, key, obj); err != nil { + return err + } + if pc, ok := obj.(*v1alpha1.ProviderConfig); ok { + pc.Spec.SSLMode = &mode + } + return nil + }} + wantMode := mode + if mode == "" || mode == "disable" { + wantMode = "require" + } + tc.fields.newDB = func(creds map[string][]byte, _ string, got string, password xsql.PasswordProvider) xsql.DB { + if got != wantMode { + t.Errorf("IAM sslmode: want %q, got %q", wantMode, got) + } + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { + t.Errorf("token: %q, %v", got, err) + } + return mockDB{} + } + cases["AWSIAMAuth/"+mode] = tc } for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &connector{kube: tc.fields.kube, track: tc.fields.track, newDB: tc.fields.newDB} + if tc.args.ctx == nil { + tc.args.ctx = context.Background() + } _, err := e.Connect(tc.args.ctx, tc.args.mg) if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index 004ab438..ef6309a7 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -39,9 +39,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -69,7 +69,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -99,7 +99,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) (managed.TypedExternalClient[*v1alpha1.DefaultPrivileges], error) { @@ -114,9 +114,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -135,7 +133,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, database, clients.ToString(pc.Spec.SSLMode))}, nil + + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + + return &external{db: c.newDB(secretData, database, sslMode, password)}, nil } type external struct { diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go index 8f828ddc..3e8256cd 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go @@ -81,7 +81,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -249,7 +249,7 @@ func TestConnectDatabaseSelection(t *testing.T) { }), }, track: func(ctx context.Context, mg resource.LegacyManaged) error { return nil }, - newDB: func(creds map[string][]byte, database string, sslmode string) xsql.DB { + newDB: func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { gotDatabase = database return mockDB{} }, diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index e76665bd..88af9edb 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -37,9 +37,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -61,7 +61,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -91,7 +91,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Extension] = &connector{} @@ -108,9 +108,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -123,13 +121,18 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + // We do not want to create an extension on the default DB // if the user was expecting a database name to be resolved. if mg.Spec.ForProvider.Database != nil { - return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, clients.ToString(pc.Spec.SSLMode))}, nil + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil } - return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, clients.ToString(pc.Spec.SSLMode))}, nil + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/postgresql/extension/reconciler_test.go b/pkg/controller/cluster/postgresql/extension/reconciler_test.go index 2baeceac..50e2c9ba 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler_test.go @@ -73,7 +73,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/grant/connect_test.go b/pkg/controller/cluster/postgresql/grant/connect_test.go index 0fa49a76..5ea46e3d 100644 --- a/pkg/controller/cluster/postgresql/grant/connect_test.go +++ b/pkg/controller/cluster/postgresql/grant/connect_test.go @@ -83,7 +83,7 @@ func TestConnectServerVersionUnavailable(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, _ string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, _ string, _ string, _ xsql.PasswordProvider) xsql.DB { return mockDB{ MockGetServerVersion: func(context.Context) (int, error) { return 0, errBoom }, } @@ -128,7 +128,7 @@ func TestConnectDatabaseGrantTargetsDefaultDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, @@ -177,7 +177,7 @@ func TestConnectObjectGrantTargetsGrantDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index de5948f6..67eb938d 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -40,9 +40,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -83,7 +83,7 @@ type connector struct { kube client.Client log logging.Logger track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } type external struct { @@ -116,9 +116,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -129,7 +127,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetSecret) } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), clients.ToString(pc.Spec.SSLMode)) + + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + + xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode, password) // A server that cannot report its version is not a reason to fail: only // table grants are version dependent, and ExpandPrivilegesWithVersion @@ -1176,7 +1180,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), diff --git a/pkg/controller/cluster/postgresql/grant/reconciler_test.go b/pkg/controller/cluster/postgresql/grant/reconciler_test.go index f90aae47..36ad2052 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler_test.go @@ -89,7 +89,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 5528967d..e2340411 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -44,9 +44,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -73,7 +73,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -104,7 +104,7 @@ type connector struct { kube client.Client apiReader client.Reader track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Role] = &connector{} @@ -121,9 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -135,8 +133,14 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + return &external{ - db: c.newDB(secretData, pc.Spec.DefaultDatabase, clients.ToString(pc.Spec.SSLMode)), + db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/cluster/postgresql/role/reconciler_test.go b/pkg/controller/cluster/postgresql/role/reconciler_test.go index afd1f63e..78c699c8 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/role/reconciler_test.go @@ -90,7 +90,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -1552,7 +1552,7 @@ func TestConnectWithSecretKeyMapping(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } cases := map[string]struct { @@ -1648,7 +1648,7 @@ func TestConnectWithSecretKeyMapping(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { var captured map[string][]byte - tc.fields.newDB = func(creds map[string][]byte, database string, sslmode string) xsql.DB { + tc.fields.newDB = func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { captured = creds return mockDB{} } diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 7d616c9c..b3b53382 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -39,9 +39,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -65,7 +65,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -97,7 +97,7 @@ var _ managed.TypedExternalConnector[*v1alpha1.Schema] = &connector{} type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.TypedExternalClient[*v1alpha1.Schema], error) { @@ -112,9 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -130,7 +128,13 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, clients.ToString(pc.Spec.SSLMode))}, nil + + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) + if err != nil { + return nil, err + } + + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil } var _ managed.TypedExternalClient[*v1alpha1.Schema] = &external{} diff --git a/pkg/controller/cluster/postgresql/schema/reconciler_test.go b/pkg/controller/cluster/postgresql/schema/reconciler_test.go index e469b3ba..e3ff06f9 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler_test.go @@ -80,7 +80,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/errors/errors.go b/pkg/controller/namespaced/errors/errors.go index 1d9dc7a3..7d026215 100644 --- a/pkg/controller/namespaced/errors/errors.go +++ b/pkg/controller/namespaced/errors/errors.go @@ -10,6 +10,7 @@ const ( errGetSecret = "cannot get credentials Secret: %s" errInvalidProviderConfigKind = "invalid ProviderConfig kind: %s" errNoSecretRef = "providerConfig does not reference a credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token: %s" ) func GetProviderConfigError(err error) error { return ErrGetProviderConfig{err} } @@ -50,6 +51,16 @@ func (e ErrInvalidProviderConfigKind) Error() string { return fmt.Sprintf(errInvalidProviderConfigKind, e.kind) } +func GenerateIAMTokenError(err error) error { return ErrGenerateIAMToken{err} } + +type ErrGenerateIAMToken struct{ error } + +func (e ErrGenerateIAMToken) Error() string { + return fmt.Sprintf(errGenerateIAMToken, e.error) +} + +func (e ErrGenerateIAMToken) Unwrap() error { return e.error } + func MissingSecretRefError() error { return ErrNoSecretRef{} } type ErrNoSecretRef struct{} diff --git a/pkg/controller/namespaced/mysql/database/reconciler.go b/pkg/controller/namespaced/mysql/database/reconciler.go index 5d356023..e3445882 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler.go +++ b/pkg/controller/namespaced/mysql/database/reconciler.go @@ -59,7 +59,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -90,7 +90,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Database] = &connector{} @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/database/reconciler_test.go b/pkg/controller/namespaced/mysql/database/reconciler_test.go index dd4d3acf..474dba09 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/database/reconciler_test.go @@ -70,7 +70,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/grant/reconciler.go b/pkg/controller/namespaced/mysql/grant/reconciler.go index 38f892c2..2e3d7648 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler.go @@ -68,7 +68,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithReferenceResolver(managed.NewAPISimpleReferenceResolver(mgr.GetClient())), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), @@ -99,7 +99,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Grant] = &connector{} @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Grant) ( return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/grant/reconciler_test.go b/pkg/controller/namespaced/mysql/grant/reconciler_test.go index 774cb182..87af983a 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 908dfc80..25515d80 100644 --- a/pkg/controller/namespaced/mysql/provider/provider.go +++ b/pkg/controller/namespaced/mysql/provider/provider.go @@ -9,6 +9,8 @@ import ( "context" "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" @@ -16,18 +18,29 @@ import ( ) type ProviderInfo struct { + PasswordProvider xsql.PasswordProvider ProviderConfigName string SecretData map[string][]byte TLS *string TLSConfig *v1alpha1.TLSConfig + // Cleartext is true when the connection uses AWS IAM auth, requiring the + // MySQL client to set allowCleartextPasswords=true. + Cleartext bool } +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider + func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( - secretKey *client.ObjectKey - tlsMode *string - tlsConfig *v1alpha1.TLSConfig - keyMapping map[string]string + secretKey *client.ObjectKey + tlsMode *string + tlsConfig *v1alpha1.TLSConfig + keyMapping map[string]string + source v1alpha1.MySQLConnectionSecretSource + region *string + configIdentity string ) switch mg.GetProviderConfigReference().Kind { @@ -50,6 +63,9 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode tlsMode = providerConfig.Spec.TLS tlsConfig = providerConfig.Spec.TLSConfig keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = providerConfig.Spec.Credentials.Source + region = providerConfig.Spec.Credentials.Region + configIdentity = "ProviderConfig/" + providerConfig.Namespace + "/" + providerConfig.Name case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ @@ -69,6 +85,9 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode tlsMode = clusterProviderConfig.Spec.TLS tlsConfig = clusterProviderConfig.Spec.TLSConfig keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = clusterProviderConfig.Spec.Credentials.Source + region = clusterProviderConfig.Spec.Credentials.Region + configIdentity = "ClusterProviderConfig/" + clusterProviderConfig.Name default: return ProviderInfo{}, errors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) @@ -84,10 +103,27 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode return ProviderInfo{}, errors.GetSecretError(err) } + secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) + + cleartext := false + var password xsql.PasswordProvider + if source == v1alpha1.CredentialsSourceAWSIAMAuth { + var err error + password, err = newIAMToken(ctx, region, secretData) + if err != nil { + return ProviderInfo{}, errors.GenerateIAMTokenError(err) + } + cleartext = true + // Default IAM connections to verified TLS using the configured trust roots. + tlsMode = mysql.EnsureTLS(tlsMode) + } + return ProviderInfo{ - ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), + PasswordProvider: password, + ProviderConfigName: configIdentity, + SecretData: secretData, TLS: tlsMode, TLSConfig: tlsConfig, + Cleartext: cleartext, }, nil } diff --git a/pkg/controller/namespaced/mysql/provider/provider_test.go b/pkg/controller/namespaced/mysql/provider/provider_test.go new file mode 100644 index 00000000..fe094926 --- /dev/null +++ b/pkg/controller/namespaced/mysql/provider/provider_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" +) + +// TestGetProviderConfigTLS covers both ProviderConfig kinds and ensures IAM +// enforces encryption without downgrading verification or changing password auth. +func TestGetProviderConfigTLS(t *testing.T) { + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil + } + t.Cleanup(func() { newIAMToken = origInject }) + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, iam := range []bool{false, true} { + for _, mode := range []string{"unset", "", "false", "preferred", "true", "skip-verify", "custom"} { + t.Run(fmt.Sprintf("%s/IAM=%t/%s", kind, iam, mode), func(t *testing.T) { + var sslMode *string + if mode != "unset" { + sslMode = &mode + } + source := v1alpha1.CredentialsSourceMySQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.TLS = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + case *v1alpha1.ClusterProviderConfig: + o.Spec.TLS = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "s", Namespace: "default"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretPasswordKey: []byte("password"), + } + } + return nil + })} + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "example"}, + }}, + } + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + wantMode := mode + if mode == "unset" { + wantMode = "" + } + if iam && (wantMode == "" || wantMode == "false" || wantMode == "preferred") { + wantMode = "true" + } + gotMode := "" + if info.TLS != nil { + gotMode = *info.TLS + } + if gotMode != wantMode { + t.Errorf("TLS: want %q, got %q", wantMode, gotMode) + } + if info.Cleartext != iam { + t.Error("cleartext plugin flag must match IAM source") + } + if !iam && mode == "unset" && info.TLS != nil { + t.Error("password auth should preserve nil TLS") + } + if iam { + if info.PasswordProvider == nil { + t.Fatal("missing per-connection token provider") + } + if got, err := info.PasswordProvider(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + } else if info.PasswordProvider != nil { + t.Error("password auth must not use IAM provider") + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "password" { + t.Errorf("Secret password changed: %q", got) + } + }) + } + } + } +} + +func TestTLSRegistrationIdentityUsesConfigurationScope(t *testing.T) { + for _, tc := range []struct{ kind, namespace, want string }{ + {v1alpha1.ClusterProviderConfigKind, "one", "ClusterProviderConfig/shared"}, + {v1alpha1.ClusterProviderConfigKind, "two", "ClusterProviderConfig/shared"}, + {v1alpha1.ProviderConfigKind, "one", "ProviderConfig/one/shared"}, + {v1alpha1.ProviderConfigKind, "two", "ProviderConfig/two/shared"}, + } { + t.Run(tc.kind+"/"+tc.namespace, func(t *testing.T) { + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(object client.Object) error { + switch pc := object.(type) { + case *v1alpha1.ProviderConfig: + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + pc.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "connection"} + case *v1alpha1.ClusterProviderConfig: + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + pc.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "connection", Namespace: "trust"} + case *corev1.Secret: + pc.Data = map[string][]byte{} + } + return nil + })} + mg := &v1alpha1.Database{ObjectMeta: metav1.ObjectMeta{Namespace: tc.namespace}, Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: tc.kind, Name: "shared"}}}} + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + if info.ProviderConfigName != tc.want { + t.Fatalf("registration identity: got %q, want %q", info.ProviderConfigName, tc.want) + } + }) + } +} diff --git a/pkg/controller/namespaced/mysql/tls/tls.go b/pkg/controller/namespaced/mysql/tls/tls.go index 2daa7120..2f121ef5 100644 --- a/pkg/controller/namespaced/mysql/tls/tls.go +++ b/pkg/controller/namespaced/mysql/tls/tls.go @@ -2,6 +2,7 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "fmt" @@ -36,7 +37,7 @@ func LoadConfig( return nil, err } - tlsName := fmt.Sprintf("custom-%s", providerConfigName) + tlsName := fmt.Sprintf("custom-namespaced-%x", sha256.Sum256([]byte(providerConfigName))) err := registerTLS(ctx, kube, tlsName, tlsConfig) if err != nil { return nil, err @@ -45,14 +46,14 @@ func LoadConfig( } func validateTLSConfig(cfg *namespacedv1alpha1.TLSConfig) error { - if cfg == nil || - cfg.CACert.SecretRef.Name == "" || - cfg.CACert.SecretRef.Key == "" || - cfg.ClientCert.SecretRef.Name == "" || - cfg.ClientCert.SecretRef.Key == "" || - cfg.ClientKey.SecretRef.Name == "" || - cfg.ClientKey.SecretRef.Key == "" { - return fmt.Errorf("tlsConfig is required when tls=custom") + if cfg == nil || cfg.CACert.SecretRef.Name == "" || cfg.CACert.SecretRef.Key == "" { + return fmt.Errorf("tlsConfig.caCert is required when tls=custom") + } + cert, key := cfg.ClientCert.SecretRef, cfg.ClientKey.SecretRef + hasCert := cert != (xpv2.SecretKeySelector{}) + hasKey := key != (xpv2.SecretKeySelector{}) + if hasCert != hasKey || (hasCert && (cert.Name == "" || cert.Key == "" || key.Name == "" || key.Key == "")) { + return fmt.Errorf("tlsConfig.clientCert and clientKey must be provided together with complete secret references") } return nil } @@ -72,16 +73,19 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *n return fmt.Errorf("cannot append CA certificate to pool") } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err - } - - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + config := &tls.Config{ + MinVersion: tls.VersionTLS12, RootCAs: pool, - Certificates: []tls.Certificate{keyPair}, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // This is only required by integration tests and should never be used in production - }) + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // Explicit operator setting retained for compatibility. + } + if cfg.ClientCert.SecretRef.Name != "" { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + config.Certificates = []tls.Certificate{keyPair} + } + return mysql.RegisterTLSConfig(tlsName, config) } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *namespacedv1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 0ebdc073..c6c7e1cd 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -64,7 +64,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,7 +94,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.User] = &connector{} @@ -117,7 +117,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.User) (m } return &external{ - db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider), kube: c.kube, }, nil } diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index 772f481c..c0bf6bd5 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/database/reconciler.go b/pkg/controller/namespaced/postgresql/database/reconciler.go index cb57d912..e1b4cadd 100644 --- a/pkg/controller/namespaced/postgresql/database/reconciler.go +++ b/pkg/controller/namespaced/postgresql/database/reconciler.go @@ -68,7 +68,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -98,7 +98,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Database] = &connector{} @@ -115,7 +115,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, err } - return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/postgresql/database/reconciler_test.go b/pkg/controller/namespaced/postgresql/database/reconciler_test.go index c7dca4b8..01a31940 100644 --- a/pkg/controller/namespaced/postgresql/database/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/database/reconciler_test.go @@ -78,7 +78,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go b/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go index aea771e7..9afa5581 100644 --- a/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go @@ -69,7 +69,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -99,7 +99,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) (managed.TypedExternalClient[*v1alpha1.DefaultPrivileges], error) { @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) database = *mg.Spec.ForProvider.Database } - return &external{db: c.newDB(providerInfo.SecretData, database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct { diff --git a/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go b/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go index 6f4a0844..abca10d3 100644 --- a/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go @@ -83,7 +83,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -297,7 +297,7 @@ func TestConnectDatabaseSelection(t *testing.T) { }), }, track: func(ctx context.Context, mg resource.ModernManaged) error { return nil }, - newDB: func(creds map[string][]byte, database string, sslmode string) xsql.DB { + newDB: func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { gotDatabase = database return mockDB{} }, diff --git a/pkg/controller/namespaced/postgresql/extension/reconciler.go b/pkg/controller/namespaced/postgresql/extension/reconciler.go index ab4cffec..ff863593 100644 --- a/pkg/controller/namespaced/postgresql/extension/reconciler.go +++ b/pkg/controller/namespaced/postgresql/extension/reconciler.go @@ -58,7 +58,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -88,7 +88,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Extension] = &connector{} @@ -108,10 +108,10 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Extensio // We do not want to create an extension on the default DB // if the user was expecting a database name to be resolved. if mg.Spec.ForProvider.Database != nil { - return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } - return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/postgresql/extension/reconciler_test.go b/pkg/controller/namespaced/postgresql/extension/reconciler_test.go index 321889ac..d96c8261 100644 --- a/pkg/controller/namespaced/postgresql/extension/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/extension/reconciler_test.go @@ -74,7 +74,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/grant/connect_test.go b/pkg/controller/namespaced/postgresql/grant/connect_test.go index 6dc66297..83871377 100644 --- a/pkg/controller/namespaced/postgresql/grant/connect_test.go +++ b/pkg/controller/namespaced/postgresql/grant/connect_test.go @@ -89,7 +89,7 @@ func TestConnectServerVersionUnavailable(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, _ string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, _ string, _ string, _ xsql.PasswordProvider) xsql.DB { return mockDB{ MockGetServerVersion: func(context.Context) (int, error) { return 0, errBoom }, } @@ -131,7 +131,7 @@ func TestConnectDatabaseGrantTargetsDefaultDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, @@ -180,7 +180,7 @@ func TestConnectObjectGrantTargetsGrantDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, diff --git a/pkg/controller/namespaced/postgresql/grant/reconciler.go b/pkg/controller/namespaced/postgresql/grant/reconciler.go index 9148b8ef..b3dca5eb 100644 --- a/pkg/controller/namespaced/postgresql/grant/reconciler.go +++ b/pkg/controller/namespaced/postgresql/grant/reconciler.go @@ -79,7 +79,7 @@ type connector struct { kube client.Client log logging.Logger track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } type external struct { @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, err } - xdb := c.newDB(providerInfo.SecretData, connectDatabase(mg.Spec.ForProvider, providerInfo.DefaultDatabase), clients.ToString(providerInfo.SSLMode)) + xdb := c.newDB(providerInfo.SecretData, connectDatabase(mg.Spec.ForProvider, providerInfo.DefaultDatabase), clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider) // A server that cannot report its version is not a reason to fail: only // table grants are version dependent, and ExpandPrivilegesWithVersion @@ -1127,7 +1127,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), diff --git a/pkg/controller/namespaced/postgresql/grant/reconciler_test.go b/pkg/controller/namespaced/postgresql/grant/reconciler_test.go index 5a656d20..5742dedf 100644 --- a/pkg/controller/namespaced/postgresql/grant/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/grant/reconciler_test.go @@ -90,7 +90,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/provider/ca_test.go b/pkg/controller/namespaced/postgresql/provider/ca_test.go new file mode 100644 index 00000000..e8e61222 --- /dev/null +++ b/pkg/controller/namespaced/postgresql/provider/ca_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestCASecretNamespaces(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + bundle := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + original := newIAMToken + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "fresh-token", nil }, nil + } + t.Cleanup(func() { newIAMToken = original }) + modes := map[string]bool{} + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, namespace := range []string{"one", "two"} { + for _, iam := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/IAM=%t", kind, namespace, iam), func(t *testing.T) { + source := v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + caNamespace := namespace + if kind == v1alpha1.ClusterProviderConfigKind { + caNamespace = "trust" + } + selected := false + mode := "verify-full" + kube := &test.MockClient{MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.SSLMode = &mode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef.Name = "connection" + o.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.LocalSecretKeySelector{LocalSecretReference: xpv2.LocalSecretReference{Name: "ca"}, Key: "bundle"}}} + case *v1alpha1.ClusterProviderConfig: + o.Spec.SSLMode = &mode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "connection", Namespace: "credentials"} + o.Spec.TLSConfig = &v1alpha1.ClusterTLSConfig{CACert: v1alpha1.ClusterTLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + case *corev1.Secret: + if key.Name == "ca" { + if key.Namespace != caNamespace { + return fmt.Errorf("unexpected CA namespace %s", key.Namespace) + } + selected = true + o.Data = map[string][]byte{"bundle": bundle} + } else { + o.Data = map[string][]byte{"endpoint": []byte("db.example"), "port": []byte("5432"), "username": []byte("user"), "password": []byte("static-password")} + } + } + return nil + }} + mg := &v1alpha1.Database{ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "same"}}}} + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + if !selected || info.SSLMode == nil || !strings.HasPrefix(*info.SSLMode, "pqgo-provider-sql-postgresql-") { + t.Fatalf("CA was not selected: %+v", info) + } + if (info.PasswordProvider != nil) != iam { + t.Fatal("CA selector changed authentication source") + } + if string(info.SecretData["password"]) != "static-password" { + t.Fatal("CA selection mutated password Secret") + } + if !iam { + identity := kind + "/" + namespace + if kind == v1alpha1.ClusterProviderConfigKind { + identity = kind + } + if kind == v1alpha1.ProviderConfigKind && modes[*info.SSLMode] { + t.Fatalf("namespace collision for %s", identity) + } + modes[*info.SSLMode] = true + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(*info.SSLMode, nil) }) + }) + } + } + } +} diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index 93652601..47b299cf 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -7,8 +7,15 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "context" + "fmt" + + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" provErrors "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" @@ -16,18 +23,27 @@ import ( ) type ProviderInfo struct { + PasswordProvider xsql.PasswordProvider ProviderConfigName string SecretData map[string][]byte DefaultDatabase string SSLMode *string } +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider + func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( secretKey *client.ObjectKey defaultDatabase string sslMode *string keyMapping map[string]string + source v1alpha1.PostgreSQLConnectionSource + region *string + caRef *xpv2.SecretKeySelector + configIdentity string ) switch mg.GetProviderConfigReference().Kind { @@ -51,6 +67,12 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode defaultDatabase = providerConfig.Spec.DefaultDatabase sslMode = providerConfig.Spec.SSLMode keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = providerConfig.Spec.Credentials.Source + region = providerConfig.Spec.Credentials.Region + configIdentity = "namespaced/ProviderConfig/" + providerConfig.Namespace + "/" + providerConfig.Name + if providerConfig.Spec.TLSConfig != nil { + caRef = providerConfig.Spec.TLSConfig.CACert.SecretRef.ToSecretKeySelector(providerConfig.Namespace) + } case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ ObjectMeta: metav1.ObjectMeta{ @@ -70,6 +92,12 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode defaultDatabase = clusterProviderConfig.Spec.DefaultDatabase sslMode = clusterProviderConfig.Spec.SSLMode keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = clusterProviderConfig.Spec.Credentials.Source + region = clusterProviderConfig.Spec.Credentials.Region + configIdentity = "namespaced/ClusterProviderConfig/" + clusterProviderConfig.Name + if clusterProviderConfig.Spec.TLSConfig != nil { + caRef = &clusterProviderConfig.Spec.TLSConfig.CACert.SecretRef + } default: return ProviderInfo{}, provErrors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) } @@ -84,10 +112,32 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode return ProviderInfo{}, provErrors.GetSecretError(err) } - return ProviderInfo{ + secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) + + return configureConnection(ctx, kube, ProviderInfo{ ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), + SecretData: secretData, DefaultDatabase: defaultDatabase, SSLMode: sslMode, - }, nil + }, source, region, configIdentity, caRef) +} + +func configureConnection(ctx context.Context, kube client.Client, info ProviderInfo, source v1alpha1.PostgreSQLConnectionSource, region *string, identity string, caRef *xpv2.SecretKeySelector) (ProviderInfo, error) { + if source == v1alpha1.CredentialsSourceAWSIAMAuth { + password, err := newIAMToken(ctx, region, info.SecretData) + if err != nil { + return ProviderInfo{}, provErrors.GenerateIAMTokenError(err) + } + info.PasswordProvider = password + mode := postgresql.EnsureTLS(clients.ToString(info.SSLMode)) + info.SSLMode = &mode + } + mode, err := postgresqltls.LoadConfig(ctx, kube, identity, clients.ToString(info.SSLMode), string(info.SecretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return ProviderInfo{}, fmt.Errorf("cannot configure PostgreSQL TLS: %w", err) + } + if caRef != nil { + info.SSLMode = &mode + } + return info, nil } diff --git a/pkg/controller/namespaced/postgresql/provider/provider_test.go b/pkg/controller/namespaced/postgresql/provider/provider_test.go new file mode 100644 index 00000000..6fe3cf4a --- /dev/null +++ b/pkg/controller/namespaced/postgresql/provider/provider_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" +) + +// TestGetProviderConfigTLS covers both ProviderConfig kinds and ensures IAM +// enforces encryption without downgrading verification or changing password auth. +func TestGetProviderConfigTLS(t *testing.T) { + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil + } + t.Cleanup(func() { newIAMToken = origInject }) + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, iam := range []bool{false, true} { + for _, mode := range []string{"unset", "", "disable", "require", "verify-ca", "verify-full"} { + t.Run(fmt.Sprintf("%s/IAM=%t/%s", kind, iam, mode), func(t *testing.T) { + var sslMode *string + if mode != "unset" { + sslMode = &mode + } + source := v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.SSLMode = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + case *v1alpha1.ClusterProviderConfig: + o.Spec.SSLMode = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "s", Namespace: "default"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretPasswordKey: []byte("password"), + } + } + return nil + })} + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "example"}, + }}, + } + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + wantMode := mode + if mode == "unset" { + wantMode = "" + } + if iam && (wantMode == "" || wantMode == "disable") { + wantMode = "require" + } + gotMode := "" + if info.SSLMode != nil { + gotMode = *info.SSLMode + } + if gotMode != wantMode { + t.Errorf("SSLMode: want %q, got %q", wantMode, gotMode) + } + if !iam && mode == "unset" && info.SSLMode != nil { + t.Error("password auth should preserve nil SSLMode") + } + if iam { + if info.PasswordProvider == nil { + t.Fatal("missing per-connection token provider") + } + if got, err := info.PasswordProvider(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + } else if info.PasswordProvider != nil { + t.Error("password auth must not use IAM provider") + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "password" { + t.Errorf("Secret password changed: %q", got) + } + }) + } + } + } +} diff --git a/pkg/controller/namespaced/postgresql/role/reconciler.go b/pkg/controller/namespaced/postgresql/role/reconciler.go index 7f3b4044..de6bb8fc 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler.go @@ -70,7 +70,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -101,7 +101,7 @@ type connector struct { kube client.Client apiReader client.Reader track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Role] = &connector{} @@ -119,7 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Role) (m } return &external{ - db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode)), + db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/namespaced/postgresql/role/reconciler_test.go b/pkg/controller/namespaced/postgresql/role/reconciler_test.go index cca25719..eadf12bb 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler_test.go @@ -91,7 +91,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/schema/reconciler.go b/pkg/controller/namespaced/postgresql/schema/reconciler.go index 53766580..8be4954b 100644 --- a/pkg/controller/namespaced/postgresql/schema/reconciler.go +++ b/pkg/controller/namespaced/postgresql/schema/reconciler.go @@ -62,7 +62,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,7 +94,7 @@ var _ managed.TypedExternalConnector[*namespacedv1alpha1.Schema] = &connector{} type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Schema) (managed.TypedExternalClient[*namespacedv1alpha1.Schema], error) { @@ -113,7 +113,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Schema) return nil, errors.New(errNoDatabase) } - return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } var _ managed.TypedExternalClient[*namespacedv1alpha1.Schema] = &external{} diff --git a/pkg/controller/namespaced/postgresql/schema/reconciler_test.go b/pkg/controller/namespaced/postgresql/schema/reconciler_test.go index 601c3554..a0179a52 100644 --- a/pkg/controller/namespaced/postgresql/schema/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/schema/reconciler_test.go @@ -82,7 +82,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/postgresql/tls/gc.go b/pkg/controller/postgresql/tls/gc.go new file mode 100644 index 00000000..0382b714 --- /dev/null +++ b/pkg/controller/postgresql/tls/gc.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "time" + + legacy "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/controller" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// Setup removes registrations for deleted configurations or removed selectors. +// A failed API list never removes entries. This runs with the controllers on +// the elected leader, whose lib/pq TLS registry is process-local. +func Setup(mgr ctrl.Manager, _ controller.Options) error { + return mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := defaultRegistry.collect(ctx, mgr.GetClient()); err != nil && ctx.Err() == nil { + mgr.GetLogger().Error(err, "cannot prune PostgreSQL TLS configurations") + } + } + } + })) +} + +func (r *tlsRegistry) collect(ctx context.Context, kube client.Client) error { + if err := r.lock(ctx); err != nil { + return err + } + empty := len(r.entries) == 0 + started := r.generation + r.unlock() + if empty { + return nil + } + // Skip entries loaded after collection started, so a stale list cannot retire + // a newly registered config that was created while these lists were running. + live, err := liveConfigurations(ctx, kube) + if err != nil { + return err + } + if err := r.lock(ctx); err != nil { + return err + } + defer r.unlock() + for identity, entry := range r.entries { + if !live[identity] && entry.generation <= started { + r.forget(identity) + } + } + return nil +} + +func liveConfigurations(ctx context.Context, kube client.Client) (map[string]bool, error) { + live := make(map[string]bool) + for _, list := range []client.ObjectList{&legacy.ProviderConfigList{}, &modern.ProviderConfigList{}, &modern.ClusterProviderConfigList{}} { + if err := kube.List(ctx, list); err != nil { + return nil, err + } + objects, err := meta.ExtractList(list) + if err != nil { + return nil, err + } + for _, object := range objects { + if identity, hasCA := configurationIdentity(object); hasCA { + live[identity] = true + } + } + } + return live, nil +} + +func configurationIdentity(object runtime.Object) (string, bool) { + switch pc := object.(type) { + case *legacy.ProviderConfig: + return "cluster/ProviderConfig/" + pc.Name, pc.Spec.TLSConfig != nil + case *modern.ProviderConfig: + return "namespaced/ProviderConfig/" + pc.Namespace + "/" + pc.Name, pc.Spec.TLSConfig != nil + case *modern.ClusterProviderConfig: + return "namespaced/ClusterProviderConfig/" + pc.Name, pc.Spec.TLSConfig != nil + default: + return "", false + } +} diff --git a/pkg/controller/postgresql/tls/lifecycle_test.go b/pkg/controller/postgresql/tls/lifecycle_test.go new file mode 100644 index 00000000..89956562 --- /dev/null +++ b/pkg/controller/postgresql/tls/lifecycle_test.go @@ -0,0 +1,410 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + legacy "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func lifecycleClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, legacy.SchemeBuilder.AddToScheme, modern.SchemeBuilder.AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testCA(t)}}) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func lifecycleRef() *xpv2.SecretKeySelector { + return &xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"} +} + +func assertRegistered(t *testing.T, mode string, want bool) { + t.Helper() + _, err := pq.NewConfig("host=db.example sslmode=" + mode) + if (err == nil) != want { + t.Fatalf("registration %s: want registered=%t, got %v", mode, want, err) + } +} + +func TestRegistrationRetirement(t *testing.T) { + r := newTLSRegistry() + kube := lifecycleClient(t) + ctx := context.Background() + identity := "cluster/ProviderConfig/retirement" + first, err := r.load(ctx, kube, identity, "verify-full", "old.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + next, err := r.load(ctx, kube, identity, "verify-ca", "new.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + assertRegistered(t, first, false) + assertRegistered(t, next, true) + mode, err := r.load(ctx, kube, identity, "verify-full", "new.example", nil) + if err != nil || mode != "verify-full" { + t.Fatalf("removed selector: %q, %v", mode, err) + } + assertRegistered(t, next, false) + if len(r.entries) != 0 { + t.Fatal("retired registration retained") + } +} + +func TestCollectDeletedConfigurations(t *testing.T) { + objects := []client.Object{ + &legacy.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live"}, Spec: legacy.ProviderConfigSpec{TLSConfig: &legacy.TLSConfig{}}}, + &modern.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: "one"}, Spec: modern.ProviderConfigSpec{TLSConfig: &modern.TLSConfig{}}}, + &modern.ClusterProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live"}, Spec: modern.ClusterProviderConfigSpec{TLSConfig: &modern.ClusterTLSConfig{}}}, + &legacy.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "removed-selector"}}, + } + kube := lifecycleClient(t, objects...) + r := newTLSRegistry() + ctx := context.Background() + modes := map[string]string{} + for _, id := range []string{"cluster/ProviderConfig/live", "namespaced/ProviderConfig/one/live", "namespaced/ClusterProviderConfig/live", "cluster/ProviderConfig/deleted", "cluster/ProviderConfig/removed-selector"} { + mode, err := r.load(ctx, kube, id, "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + modes[id] = mode + } + if err := r.collect(ctx, kube); err != nil { + t.Fatal(err) + } + for id, mode := range modes { + want := id == "cluster/ProviderConfig/live" || id == "namespaced/ProviderConfig/one/live" || id == "namespaced/ClusterProviderConfig/live" + assertRegistered(t, mode, want) + } + if len(r.entries) != 3 { + t.Fatalf("want three live configurations, got %d", len(r.entries)) + } + failed := &test.MockClient{MockList: func(context.Context, client.ObjectList, ...client.ListOption) error { + return errors.New("list unavailable") + }} + if err := r.collect(ctx, failed); err == nil { + t.Fatal("expected list failure") + } + if len(r.entries) != 3 { + t.Fatal("failed list removed live configurations") + } + for id := range r.entries { + r.forget(id) + } +} + +type waitingContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *waitingContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +func TestReloadWaiterCancellation(t *testing.T) { + r := newTLSRegistry() + if err := r.lock(context.Background()); err != nil { + t.Fatal(err) + } + defer r.unlock() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observed := &waitingContext{Context: ctx, waiting: make(chan struct{})} + done := make(chan error, 1) + go func() { + _, err := r.load(observed, nil, "waiting", "verify-full", "db.example", lifecycleRef()) + done <- err + }() + select { + case <-observed.waiting: + case <-time.After(5 * time.Second): + t.Fatal("caller did not wait on registry lock") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("cancelled caller remained blocked") + } +} + +type blockedListClient struct { + client.Client + started, release chan struct{} + once sync.Once +} + +func (c *blockedListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + c.once.Do(func() { close(c.started); <-c.release }) + return c.Client.List(ctx, list, opts...) +} + +func TestCollectDoesNotRetireConcurrentRegistration(t *testing.T) { + r := newTLSRegistry() + kube := lifecycleClient(t) + ctx := context.Background() + old, err := r.load(ctx, kube, "cluster/ProviderConfig/old", "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + blocked := &blockedListClient{Client: kube, started: make(chan struct{}), release: make(chan struct{})} + done := make(chan error, 1) + var release sync.Once + defer release.Do(func() { close(blocked.release) }) + go func() { done <- r.collect(ctx, blocked) }() + select { + case <-blocked.started: + case <-time.After(5 * time.Second): + t.Fatal("collector did not start listing") + } + fresh, err := r.load(ctx, kube, "cluster/ProviderConfig/fresh", "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + release.Do(func() { close(blocked.release) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("collector did not finish") + } + assertRegistered(t, old, false) + assertRegistered(t, fresh, true) + if err := r.collect(ctx, kube); err != nil { + t.Fatal(err) + } + assertRegistered(t, fresh, false) +} + +func TestSecretReadsSerialized(t *testing.T) { + r := newTLSRegistry() + firstCA, secondCA := testCA(t), testCA(t) + started, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var reads atomic.Int32 + kube := &test.MockClient{MockGet: func(_ context.Context, _ client.ObjectKey, object client.Object) error { + ca := secondCA + if reads.Add(1) == 1 { + close(started) + <-release + ca = firstCA + } + object.(*corev1.Secret).Data = map[string][]byte{"bundle": ca} + return nil + }} + type result struct { + mode string + err error + } + results := make(chan result, 2) + load := func(ctx context.Context) { + mode, err := r.load(ctx, kube, "cluster/ProviderConfig/serialized", "verify-full", "db.example", lifecycleRef()) + results <- result{mode, err} + } + go load(context.Background()) + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first read did not start") + } + second := &waitingContext{Context: context.Background(), waiting: make(chan struct{})} + go load(second) + select { + case <-second.waiting: + case <-time.After(5 * time.Second): + t.Fatal("second reader did not reach lock") + } + if reads.Load() != 1 { + t.Fatal("second Secret read overlapped the first registration") + } + releaseOnce.Do(func() { close(release) }) + var previous string + for range 2 { + select { + case result := <-results: + if result.err != nil { + t.Fatal(result.err) + } + if previous != "" && result.mode != previous { + t.Fatal("CA rotation changed registration identity") + } + previous = result.mode + case <-time.After(5 * time.Second): + t.Fatal("serialized reload did not complete") + } + } + if reads.Load() != 2 { + t.Fatalf("want two ordered reads, got %d", reads.Load()) + } + r.forget("cluster/ProviderConfig/serialized") +} + +func TestSelectorChangesFailClosed(t *testing.T) { + for _, field := range []string{"namespace", "name", "key"} { + t.Run(field, func(t *testing.T) { + r := newTLSRegistry() + ref := lifecycleRef() + switch field { + case "namespace": + ref.Namespace = "other-trust" + case "name": + ref.Name = "other-ca" + case "key": + ref.Key = "other-bundle" + } + kube := lifecycleClient(t) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: ref.Name, Namespace: ref.Namespace}, Data: map[string][]byte{ref.Key: testCA(t)}} + if field == "key" { + if err := kube.Get(context.Background(), client.ObjectKeyFromObject(secret), secret); err != nil { + t.Fatal(err) + } + secret.Data[ref.Key] = testCA(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + } else if err := kube.Create(context.Background(), secret); err != nil { + t.Fatal(err) + } + identity := "cluster/ProviderConfig/selector-" + field + old, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + next, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", ref) + if err != nil { + t.Fatal(err) + } + if next == old { + t.Fatal("different CA selectors reused the same trust registration") + } + assertRegistered(t, next, true) + // An in-flight reconcile with the previous selector must not replace + // the new selector's trust pool under an otherwise unchanged name. + if _, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", lifecycleRef()); err != nil { + t.Fatal(err) + } + assertRegistered(t, next, false) + r.forget(identity) + }) + } +} + +func TestNoCAReconcileBypassesBusyRegistry(t *testing.T) { + r := newTLSRegistry() + if err := r.lock(context.Background()); err != nil { + t.Fatal(err) + } + defer r.unlock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + mode, err := r.load(ctx, nil, "unused", "require", "db.example", nil) + if err != nil || mode != "require" { + t.Fatalf("no-CA reconcile waited behind the registry: mode=%q err=%v", mode, err) + } +} + +func TestNoCAReconcilePreservesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := newTLSRegistry().load(ctx, nil, "unused", "verify-full", "db.example", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } +} + +func TestSelectorRemovalWaitsForFirstCARead(t *testing.T) { + r := newTLSRegistry() + started, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + ca := testCA(t) + kube := &test.MockClient{MockGet: func(_ context.Context, _ client.ObjectKey, object client.Object) error { + close(started) + <-release + object.(*corev1.Secret).Data = map[string][]byte{"bundle": ca} + return nil + }} + type result struct { + mode string + err error + } + loaded := make(chan result, 1) + go func() { + mode, err := r.load(context.Background(), kube, "first-load", "verify-full", "db.example", lifecycleRef()) + loaded <- result{mode, err} + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("CA read did not start") + } + waiting := &waitingContext{Context: context.Background(), waiting: make(chan struct{})} + removed := make(chan result, 1) + go func() { + mode, err := r.load(waiting, nil, "first-load", "verify-full", "db.example", nil) + removed <- result{mode, err} + }() + select { + case <-waiting.waiting: + case result := <-removed: + t.Fatalf("selector removal bypassed an in-progress CA read: %+v", result) + case <-time.After(5 * time.Second): + t.Fatal("selector removal did not reach the gate") + } + releaseOnce.Do(func() { close(release) }) + first := <-loaded + if first.err != nil { + t.Fatal(first.err) + } + next := <-removed + if next.err != nil || next.mode != "verify-full" { + t.Fatalf("selector removal: %+v", next) + } + assertRegistered(t, first.mode, false) + if len(r.entries) != 0 { + t.Fatal("removed selector retained its registration") + } +} diff --git a/pkg/controller/postgresql/tls/tls.go b/pkg/controller/postgresql/tls/tls.go new file mode 100644 index 00000000..d2201260 --- /dev/null +++ b/pkg/controller/postgresql/tls/tls.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package tls loads PostgreSQL server CA bundles from ProviderConfig Secrets. +package tls + +import ( + "context" + "crypto/sha256" + "fmt" + "sync" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" +) + +type registration struct { + name string + generation uint64 +} + +type tlsRegistry struct { + // Activity counts registered configurations and in-progress CA loads. It is + // separate from the gate so an unrelated no-CA reconcile never waits for I/O. + activityMu sync.RWMutex + activity map[string]int + gate chan struct{} + entries map[string]registration + generation uint64 +} + +func newTLSRegistry() *tlsRegistry { + return &tlsRegistry{gate: make(chan struct{}, 1), entries: make(map[string]registration), activity: make(map[string]int)} +} + +var defaultRegistry = newTLSRegistry() + +func (r *tlsRegistry) lock(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + select { + case r.gate <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *tlsRegistry) unlock() { <-r.gate } + +func (r *tlsRegistry) track(identity string, delta int) { + r.activityMu.Lock() + defer r.activityMu.Unlock() + if n := r.activity[identity] + delta; n > 0 { + r.activity[identity] = n + } else { + delete(r.activity, identity) + } +} + +func (r *tlsRegistry) active(identity string) bool { + r.activityMu.RLock() + defer r.activityMu.RUnlock() + return r.activity[identity] != 0 +} + +// forget is called while holding the registry lock. +func (r *tlsRegistry) forget(identity string) { + if old, ok := r.entries[identity]; ok { + _ = pq.RegisterTLSConfig(old.name, nil) + delete(r.entries, identity) + r.track(identity, -1) + } +} + +// LoadConfig reads and registers the CA on each reconcile, so Secret rotation +// takes effect without restarting the provider. Identity must include the API +// scope, ProviderConfig kind, namespace and name to keep trust pools separate. +// Without a selector, the driver's existing trust-store behavior is preserved. +func LoadConfig(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + return defaultRegistry.load(ctx, kube, identity, mode, endpoint, ref) +} + +func (r *tlsRegistry) load(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if ref == nil { + if !r.active(identity) { + return mode, nil + } + } else { + // Mark this load before waiting for the gate. Removing a selector must not + // return early while its first CA read is still in progress. + r.track(identity, 1) + defer r.track(identity, -1) + } + + // Serialize the complete read/parse/register sequence. Otherwise an older + // Secret read could overwrite a newer bundle from a concurrent reconcile. + if err := r.lock(ctx); err != nil { + return "", err + } + defer r.unlock() + if ref == nil { + r.forget(identity) + return mode, nil + } + return r.register(ctx, kube, identity, mode, endpoint, ref) +} + +// register requires the gate to cover the complete read/parse/register sequence. +func (r *tlsRegistry) register(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + if mode == "" { + mode = "verify-full" + } + ca, err := readCA(ctx, kube, ref) + if err != nil { + return "", err + } + config, err := postgresql.TLSConfig(mode, endpoint, ca) + if err != nil { + return "", err + } + // Keep a stable registration per configuration, mode and endpoint. Replacing + // it refreshes this configuration's CA without changing another config's trust. + name := fmt.Sprintf("provider-sql-postgresql-%x", sha256.Sum256([]byte(identity+"\x00"+mode+"\x00"+endpoint+"\x00"+ref.Namespace+"\x00"+ref.Name+"\x00"+ref.Key))) + if err := pq.RegisterTLSConfig(name, config); err != nil { + return "", fmt.Errorf("cannot register PostgreSQL TLS configuration: %w", err) + } + if previous, ok := r.entries[identity]; !ok { + r.track(identity, 1) + } else if previous.name != name { + // Retire the old registration immediately. A reconcile holding its name may + // fail to open its next connection and retry; established connections stay + // open. Updating the CA contents keeps the same name and avoids this case. + _ = pq.RegisterTLSConfig(previous.name, nil) + } + r.generation++ + r.entries[identity] = registration{name: name, generation: r.generation} + return "pqgo-" + name, nil +} + +func readCA(ctx context.Context, kube client.Client, ref *xpv2.SecretKeySelector) ([]byte, error) { + if ref.Name == "" || ref.Namespace == "" || ref.Key == "" { + return nil, fmt.Errorf("tlsConfig.caCert.secretRef requires name, namespace and key") + } + secret := &corev1.Secret{} + if err := kube.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, secret); err != nil { + return nil, fmt.Errorf("cannot get PostgreSQL CA Secret: %w", err) + } + ca, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("CA Secret %s/%s does not contain key %q", ref.Namespace, ref.Name, ref.Key) + } + return ca, nil +} diff --git a/pkg/controller/postgresql/tls/tls_test.go b/pkg/controller/postgresql/tls/tls_test.go new file mode 100644 index 00000000..deacd180 --- /dev/null +++ b/pkg/controller/postgresql/tls/tls_test.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func testCA(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestLoadConfig(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "one"}, Data: map[string][]byte{"bundle": testCA(t), "empty": {}, "bad": []byte("PRIVATE-MATERIAL-MUST-NOT-APPEAR")}}).Build() + ref := func(ns, name, key string) *xpv2.SecretKeySelector { + return &xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Namespace: ns, Name: name}, Key: key} + } + for _, tc := range []struct { + name, mode string + ref *xpv2.SecretKeySelector + want string + }{ + {"missing namespace", "verify-full", ref("", "ca", "bundle"), "requires name, namespace and key"}, + {"missing name", "verify-full", ref("one", "", "bundle"), "requires name, namespace and key"}, + {"missing key", "verify-full", ref("one", "ca", ""), "requires name, namespace and key"}, + {"other namespace cannot borrow secret", "verify-full", ref("two", "ca", "bundle"), "not found"}, + {"missing data key", "verify-full", ref("one", "ca", "absent"), "does not contain key"}, + {"empty PEM", "verify-full", ref("one", "ca", "empty"), "valid PEM certificate"}, + {"invalid PEM", "verify-full", ref("one", "ca", "bad"), "valid PEM certificate"}, + {"require rejected", "require", ref("one", "ca", "bundle"), "requires sslMode verify-ca or verify-full"}, + {"disabled rejected", "disable", ref("one", "ca", "bundle"), "requires sslMode verify-ca or verify-full"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadConfig(context.Background(), kube, "cluster/ProviderConfig/test", tc.mode, "db.example", tc.ref) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %q, got %v", tc.want, err) + } + if strings.Contains(err.Error(), "PRIVATE-MATERIAL") { + t.Fatal("error disclosed Secret contents") + } + }) + } + for _, mode := range []string{"", "require", "disable", "verify-ca", "verify-full"} { + got, err := LoadConfig(context.Background(), nil, "unused", mode, "db.example", nil) + if err != nil || got != mode { + t.Fatalf("omitted selector changed mode %q: %q, %v", mode, got, err) + } + } + modes := map[string]bool{} + for _, identity := range []string{"cluster/ProviderConfig/same", "namespaced/ClusterProviderConfig/same", "namespaced/ProviderConfig/one/same", "namespaced/ProviderConfig/two/same"} { + for _, mode := range []string{"verify-full", "verify-ca"} { + got, err := LoadConfig(context.Background(), kube, identity, mode, "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + if modes[got] { + t.Fatalf("registration collision for %s/%s", identity, mode) + } + modes[got] = true + if _, err := pq.NewConfig("host=db.example sslmode=" + got); err != nil { + t.Fatalf("registered mode rejected by real driver: %v", err) + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(got, nil) }) + } + } + // Rotation replaces a stable per-config registration rather than growing a + // new global registration on every reconcile or borrowing another config's CA. + identity := "namespaced/ProviderConfig/one/rotated" + before, err := LoadConfig(context.Background(), kube, identity, "verify-full", "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{} + if err := kube.Get(context.Background(), client.ObjectKey{Namespace: "one", Name: "ca"}, secret); err != nil { + t.Fatal(err) + } + secret.Data["bundle"] = testCA(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + after, err := LoadConfig(context.Background(), kube, identity, "verify-full", "db.example", ref("one", "ca", "bundle")) + if err != nil || after != before { + t.Fatalf("rotation: %q -> %q, %v", before, after, err) + } + _ = pq.RegisterTLSConfig(after, nil) + unset, err := LoadConfig(context.Background(), kube, "default-mode", "", "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + _ = pq.RegisterTLSConfig(unset, nil) +} diff --git a/pkg/controller/sql.go b/pkg/controller/sql.go index 2f4c7d51..749ee866 100644 --- a/pkg/controller/sql.go +++ b/pkg/controller/sql.go @@ -27,6 +27,7 @@ import ( namespacedmssql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mssql" namespacedmysql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql" namespacedpostgresql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/postgresql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) // Setup creates all controllers with the supplied logger and adds @@ -39,6 +40,7 @@ func Setup(mgr ctrl.Manager, l controller.Options) error { namespacedmssql.Setup, namespacedmysql.Setup, namespacedpostgresql.Setup, + postgresqltls.Setup, } { if err := setup(mgr, l); err != nil { return err