From bf2cf14af501ba8c434cd9c9434f4a6cd5b88017 Mon Sep 17 00:00:00 2001 From: krogon-bt <218959328+krogon-bt@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:14:38 +0200 Subject: [PATCH 1/2] feat(postgresql): add Aurora IAM auth (#1) Tokens expire in 15 minutes and each query opens a new connection, so mint one per connect from the AWS default chain. Keep the configured sslMode, including verify-full, and load the RDS CA from tlsConfig.caCert. The provider image does not ship that bundle. Refs #347 Signed-off-by: krogon-bt <218959328+krogon-bt@users.noreply.github.com> --- .../postgresql/v1alpha1/provider_types.go | 46 +++- .../v1alpha1/zz_generated.deepcopy.go | 57 +++++ .../v1alpha1/cluster_provider_types.go | 14 +- .../postgresql/v1alpha1/provider_types.go | 49 ++++- .../v1alpha1/zz_generated.deepcopy.go | 67 ++++++ docs/aws-iam-auth.md | 51 +++++ examples/cluster/postgresql/config_iam.yaml | 35 ++++ .../postgresql/clusterconfig_iam.yaml | 19 ++ .../namespaced/postgresql/config_iam.yaml | 22 ++ go.mod | 15 ++ go.sum | 30 +++ ...sql.sql.crossplane.io_providerconfigs.yaml | 35 ++++ ....crossplane.io_clusterproviderconfigs.yaml | 37 ++++ ...l.sql.m.crossplane.io_providerconfigs.yaml | 38 ++++ pkg/clients/awsiam/awsiam.go | 112 ++++++++++ pkg/clients/awsiam/awsiam_test.go | 196 ++++++++++++++++++ pkg/clients/postgresql/postgresql_test.go | 20 ++ pkg/clients/postgresql/tls.go | 93 +++++++++ pkg/clients/postgresql/tls_test.go | 125 +++++++++++ .../cluster/postgresql/database/reconciler.go | 12 +- .../postgresql/database/reconciler_test.go | 50 +++++ .../default_privileges/reconciler.go | 12 +- .../postgresql/extension/reconciler.go | 14 +- .../cluster/postgresql/grant/reconciler.go | 12 +- .../cluster/postgresql/pgconn/pgconn.go | 40 ++++ .../cluster/postgresql/role/reconciler.go | 12 +- .../cluster/postgresql/schema/reconciler.go | 12 +- .../postgresql/provider/provider.go | 161 +++++++++----- .../postgresql/provider/provider_test.go | 184 ++++++++++++++++ pkg/controller/postgresql/connect/connect.go | 148 +++++++++++++ .../postgresql/connect/connect_test.go | 167 +++++++++++++++ 31 files changed, 1799 insertions(+), 86 deletions(-) create mode 100644 docs/aws-iam-auth.md create mode 100644 examples/cluster/postgresql/config_iam.yaml create mode 100644 examples/namespaced/postgresql/clusterconfig_iam.yaml create mode 100644 examples/namespaced/postgresql/config_iam.yaml create mode 100644 pkg/clients/awsiam/awsiam.go create mode 100644 pkg/clients/awsiam/awsiam_test.go create mode 100644 pkg/clients/postgresql/tls.go create mode 100644 pkg/clients/postgresql/tls_test.go create mode 100644 pkg/controller/cluster/postgresql/pgconn/pgconn.go create mode 100644 pkg/controller/namespaced/postgresql/provider/provider_test.go create mode 100644 pkg/controller/postgresql/connect/connect.go create mode 100644 pkg/controller/postgresql/connect/connect_test.go diff --git a/apis/cluster/postgresql/v1alpha1/provider_types.go b/apis/cluster/postgresql/v1alpha1/provider_types.go index e6c993d9..2f0f2844 100644 --- a/apis/cluster/postgresql/v1alpha1/provider_types.go +++ b/apis/cluster/postgresql/v1alpha1/provider_types.go @@ -36,6 +36,39 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig provides the CA bundle used when sslMode is verify-ca or + // verify-full. The secret key is one PEM and may contain multiple + // certificates, such as the Amazon RDS global bundle. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig provides a CA bundle for sslMode verify-ca and verify-full. +type TLSConfig struct { + // CACert is a PEM bundle of root and intermediate certificates. + // +optional + CACert TLSSecret `json:"caCert,omitempty"` +} + +// TLSSecret is a reference to a key that contains TLS material in PEM format. +type TLSSecret struct { + // SecretRef selects the secret key. + // +optional + SecretRef TLSSecretRef `json:"secretRef,omitempty"` +} + +// TLSSecretRef selects a secret key. Name and key are required. Namespace is +// required on a cluster-scoped ProviderConfig. +type TLSSecretRef struct { + // Name of the secret. + Name string `json:"name"` + + // Namespace of the secret. + Namespace string `json:"namespace"` + + // Key to select. + Key string `json:"key"` } const ( @@ -43,12 +76,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 +99,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..01f7c796 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,50 @@ 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 +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecretRef) DeepCopyInto(out *TLSSecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecretRef. +func (in *TLSSecretRef) DeepCopy() *TLSSecretRef { + if in == nil { + return nil + } + out := new(TLSSecretRef) + in.DeepCopyInto(out) + return out +} diff --git a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go index 602b97ba..17b3e0cf 100644 --- a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go @@ -36,12 +36,18 @@ type ClusterProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig provides the CA bundle used when sslMode is verify-ca or + // verify-full. The secret key is one PEM and may contain multiple + // certificates, such as the Amazon RDS global bundle. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` } // 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 +60,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..9fbc0607 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -36,6 +36,42 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig provides the CA bundle used when sslMode is verify-ca or + // verify-full. The secret key is one PEM and may contain multiple + // certificates, such as the Amazon RDS global bundle. An empty secret + // namespace means this ProviderConfig's namespace. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig provides a CA bundle for sslMode verify-ca and verify-full. +type TLSConfig struct { + // CACert is a PEM bundle of root and intermediate certificates. + // +optional + CACert TLSSecret `json:"caCert,omitempty"` +} + +// TLSSecret is a reference to a key that contains TLS material in PEM format. +type TLSSecret struct { + // SecretRef selects the secret key. Namespace may be omitted on a + // namespaced ProviderConfig; it then defaults to that config's namespace. + // +optional + SecretRef TLSSecretRef `json:"secretRef,omitempty"` +} + +// TLSSecretRef selects a secret key. Name and key are required. Namespace is +// optional on a namespaced ProviderConfig and required on a ClusterProviderConfig. +type TLSSecretRef struct { + // Name of the secret. + Name string `json:"name"` + + // Namespace of the secret. Optional on a namespaced ProviderConfig. + // +optional + Namespace string `json:"namespace,omitempty"` + + // Key to select. + Key string `json:"key"` } type PostgreSQLConnectionSource string @@ -45,12 +81,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 +104,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..86cecb52 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(TLSConfig) + **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. @@ -897,6 +907,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 +1007,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 +1435,50 @@ 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 +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecretRef) DeepCopyInto(out *TLSSecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecretRef. +func (in *TLSSecretRef) DeepCopy() *TLSSecretRef { + if in == nil { + return nil + } + out := new(TLSSecretRef) + in.DeepCopyInto(out) + return out +} diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md new file mode 100644 index 00000000..291832a2 --- /dev/null +++ b/docs/aws-iam-auth.md @@ -0,0 +1,51 @@ +# AWS IAM database authentication + +provider-sql can authenticate to Amazon Aurora PostgreSQL with [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). On each connect it generates a short-lived token from the AWS SDK default credential chain and uses that token as the database password. On EKS, [Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) supplies those credentials. The token is not cached, so a provider process that runs longer than the 15-minute token lifetime keeps reconciling. A single reconcile that itself lasts longer than 15 minutes and then opens another connection can fail. Normal SQL reconciles are much shorter. + +```yaml +spec: + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + name: aurora-conn +``` + +`connectionSecretRef` still points at a Secret. Pod Identity does not know which cluster or database user to connect as. The Secret holds `endpoint`, `port`, and `username`. It does not need a `password`. `username` is the database role the token is signed for. + +Pod Identity does not set `AWS_REGION`. Set `spec.credentials.region`, a `region` key in the connection secret, or `AWS_REGION` on the provider Deployment. The region is resolved in that order. An empty region is an error, because the SDK will sign a token with a blank region. + +## Pod Identity + +Attach an `rds-db:connect` policy to the Pod Identity role for the database user in the connection secret. Associate that role with the provider service account. `rds-db:connect` is an AWS IAM permission. The provider does not create it. + +Enable IAM database authentication on the Aurora cluster. Aurora PostgreSQL 16 uses the same token as other Aurora PostgreSQL versions. The endpoint in the secret must be the name on the server certificate, because `sslMode: verify-full` checks the hostname. Use the cluster endpoint. + +## Database user + +This feature does not create the database login. A `Role` already creates a login, and a `Grant` with `memberOf: rds_iam` runs `GRANT rds_iam TO `. On Aurora that grant disables password login for the role. Do not grant `rds_iam` to the master user. + +The first grant has to run through a password `ProviderConfig` connected as the master user. After an IAM login exists that can create roles and grant `rds_iam`, later users can be created through the IAM `ProviderConfig`. + +## TLS + +`sslMode` is passed through. `disable`, `require`, `verify-ca`, and `verify-full` are all accepted. The CRD default is `verify-full`. Aurora rejects an unencrypted IAM login, so `disable` fails at the server. + +`verify-full` checks the certificate chain and the hostname. `verify-ca` checks the chain only. `require` encrypts without using `tlsConfig`. + +`tlsConfig.caCert` is the CA, read from a Secret the same way MySQL `tls: custom` reads a certificate. There is no client certificate. The secret key is one PEM, the [Amazon RDS global bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem). That file already concatenates the RDS root CAs and the regional intermediate CAs. Every `CERTIFICATE` block is loaded into one trust pool. The provider image does not include this bundle. + +On a namespaced `ProviderConfig`, omit `secretRef.namespace` to use the ProviderConfig namespace. A `ClusterProviderConfig` and a cluster-scoped `ProviderConfig` set the namespace. + +Examples: + +- `examples/cluster/postgresql/config_iam.yaml` +- `examples/namespaced/postgresql/config_iam.yaml` +- `examples/namespaced/postgresql/clusterconfig_iam.yaml` diff --git a/examples/cluster/postgresql/config_iam.yaml b/examples/cluster/postgresql/config_iam.yaml new file mode 100644 index 00000000..f0e0c4cf --- /dev/null +++ b/examples/cluster/postgresql/config_iam.yaml @@ -0,0 +1,35 @@ +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn + +# The connection secret holds endpoint, port, and username. The provider +# generates a short-lived IAM token and uses it as the password. +# The CA secret holds the Amazon RDS global bundle, one PEM containing the +# root and intermediate certificates: +# https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem +# --- +# 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/postgresql/clusterconfig_iam.yaml b/examples/namespaced/postgresql/clusterconfig_iam.yaml new file mode 100644 index 00000000..0fa82ce6 --- /dev/null +++ b/examples/namespaced/postgresql/clusterconfig_iam.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ClusterProviderConfig +metadata: + name: aurora-iam +spec: + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn diff --git a/examples/namespaced/postgresql/config_iam.yaml b/examples/namespaced/postgresql/config_iam.yaml new file mode 100644 index 00000000..788d6a41 --- /dev/null +++ b/examples/namespaced/postgresql/config_iam.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + name: aurora-conn + +# amazon-rds-ca and aurora-conn live in the ProviderConfig namespace. +# aurora-conn holds endpoint, port, and username. No password. +# amazon-rds-ca key global-bundle.pem is the Amazon RDS global bundle. 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/postgresql.sql.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml index b6e9c167..3a057fb4 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,6 +125,34 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig provides the CA bundle used when sslMode is verify-ca or + verify-full. The secret key is one PEM and may contain multiple + certificates, such as the Amazon RDS global bundle. + properties: + caCert: + description: CACert is a PEM bundle of root and intermediate certificates. + properties: + secretRef: + description: SecretRef selects the secret key. + properties: + key: + description: 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 + type: object + type: object required: - credentials type: object diff --git a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml index 35a5950a..d33930a4 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,6 +126,36 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig provides the CA bundle used when sslMode is verify-ca or + verify-full. The secret key is one PEM and may contain multiple + certificates, such as the Amazon RDS global bundle. + properties: + caCert: + description: CACert is a PEM bundle of root and intermediate certificates. + properties: + secretRef: + description: |- + SecretRef selects the secret key. Namespace may be omitted on a + namespaced ProviderConfig; it then defaults to that config's namespace. + properties: + key: + description: Key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. Optional on a namespaced + ProviderConfig. + type: string + required: + - key + - name + type: object + type: object + type: object required: - credentials type: object diff --git a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml index 0be259a2..63f2fe30 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,6 +121,37 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig provides the CA bundle used when sslMode is verify-ca or + verify-full. The secret key is one PEM and may contain multiple + certificates, such as the Amazon RDS global bundle. An empty secret + namespace means this ProviderConfig's namespace. + properties: + caCert: + description: CACert is a PEM bundle of root and intermediate certificates. + properties: + secretRef: + description: |- + SecretRef selects the secret key. Namespace may be omitted on a + namespaced ProviderConfig; it then defaults to that config's namespace. + properties: + key: + description: Key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. Optional on a namespaced + ProviderConfig. + type: string + required: + - key + - name + type: object + type: object + type: object required: - credentials type: object diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go new file mode 100644 index 00000000..29bcb919 --- /dev/null +++ b/pkg/clients/awsiam/awsiam.go @@ -0,0 +1,112 @@ +/* +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 generates short-lived AWS RDS IAM authentication tokens and +// injects them in place of a static database password. Credentials come from +// the AWS SDK default chain, which includes EKS Pod Identity. +package awsiam + +import ( + "context" + + "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" +) + +// SourceAWSIAMAuth is the ProviderConfig credentials source that authenticates +// with a generated RDS IAM token instead of a static password. +const SourceAWSIAMAuth = "AWSIAMAuth" + +// TokenBuilder has the same signature as auth.BuildAuthToken from the AWS SDK. +// InjectToken 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 +} + +// InjectToken generates an RDS IAM authentication token and writes it into creds +// as the password. It reads the endpoint, port and username already present in +// creds (populated from the connection secret) and combines endpoint and port +// into the "host:port" form the AWS SDK requires. awsCreds are the AWS +// credentials used to sign the token; build is the token generator +// (auth.BuildAuthToken in production). +// +// On success the password entry of creds holds the token; the database client +// then uses it exactly as it would a static password. Each call builds a new +// token. Nothing here is cached. +func InjectToken(ctx context.Context, creds map[string][]byte, region string, + awsCreds aws.CredentialsProvider, build TokenBuilder) error { + endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) + port := string(creds[xpv2.CredentialsSecretPortKey]) + username := string(creds[xpv2.CredentialsSecretUserKey]) + + if endpoint == "" || port == "" || username == "" { + return errors.New(errMissingConnDetails) + } + if region == "" { + return errors.New(errNoRegion) + } + + token, err := build(ctx, endpoint+":"+port, region, username, awsCreds) + if err != nil { + return errors.Wrap(err, errBuildToken) + } + + creds[xpv2.CredentialsSecretPasswordKey] = []byte(token) + return nil +} + +// Inject loads AWS configuration from the environment, resolves the region +// (ProviderConfig field > secret "region" key > environment) and injects an RDS +// IAM authentication token into creds as the password. It is called on every +// connect when the credentials source is AWS IAM auth. +func Inject(ctx context.Context, specRegion *string, creds map[string][]byte) error { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return errors.Wrap(err, errLoadConfig) + } + region := ResolveRegion(specRegion, creds, cfg.Region) + return InjectToken(ctx, creds, region, cfg.Credentials, auth.BuildAuthToken) +} diff --git a/pkg/clients/awsiam/awsiam_test.go b/pkg/clients/awsiam/awsiam_test.go new file mode 100644 index 00000000..6b2ce5da --- /dev/null +++ b/pkg/clients/awsiam/awsiam_test.go @@ -0,0 +1,196 @@ +/* +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" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// 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 int +} + +// 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 = endpoint, region, dbUser + rec.called++ + 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 TestInjectToken(t *testing.T) { + ctx := context.Background() + errBoom := errors.New("boom") + + // The stub builder ignores the credentials argument, so any value works. + var noCreds aws.CredentialsProvider + + // fullCreds is a connection-secret map with everything InjectToken needs. + fullCreds := func() map[string][]byte { + return map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + + t.Run("Success", func(t *testing.T) { + creds := fullCreds() + rec := &builderCall{} + + err := InjectToken(ctx, creds, "eu-west-1", noCreds, recordingBuilder("FAKE_TOKEN", nil, rec)) + if err != nil { + t.Fatalf("InjectToken(...): unexpected error: %v", err) + } + + if got := string(creds[xpv2.CredentialsSecretPasswordKey]); got != "FAKE_TOKEN" { + t.Errorf("password: want %q, got %q", "FAKE_TOKEN", got) + } + if rec.endpoint != "db.example.rds.amazonaws.com:5432" { + t.Errorf("endpoint passed to builder: want host:port, got %q", rec.endpoint) + } + if rec.region != "eu-west-1" { + t.Errorf("region passed to builder: want %q, got %q", "eu-west-1", rec.region) + } + if rec.dbUser != "crossplane_admin" { + t.Errorf("dbUser passed to builder: want %q, got %q", "crossplane_admin", rec.dbUser) + } + }) + + t.Run("SecondCallBuildsAgain", func(t *testing.T) { + creds := fullCreds() + rec := &builderCall{} + build := recordingBuilder("FAKE_TOKEN", nil, rec) + + if err := InjectToken(ctx, creds, "eu-west-1", noCreds, build); err != nil { + t.Fatalf("first InjectToken(...): %v", err) + } + if err := InjectToken(ctx, creds, "eu-west-1", noCreds, build); err != nil { + t.Fatalf("second InjectToken(...): %v", err) + } + if rec.called != 2 { + t.Errorf("token builder calls: want 2, got %d", rec.called) + } + }) + + t.Run("MissingUsername", func(t *testing.T) { + creds := fullCreds() + delete(creds, xpv2.CredentialsSecretUserKey) + rec := &builderCall{} + + err := InjectToken(ctx, creds, "eu-west-1", noCreds, recordingBuilder("x", nil, rec)) + if diff := cmp.Diff(errors.New(errMissingConnDetails), err, test.EquateErrors()); diff != "" { + t.Errorf("InjectToken(...): -want error, +got error:\n%s", diff) + } + if rec.called != 0 { + t.Error("builder must not be called when connection details are incomplete") + } + }) + + t.Run("EmptyRegion", func(t *testing.T) { + creds := fullCreds() + rec := &builderCall{} + + err := InjectToken(ctx, creds, "", noCreds, recordingBuilder("x", nil, rec)) + if diff := cmp.Diff(errors.New(errNoRegion), err, test.EquateErrors()); diff != "" { + t.Errorf("InjectToken(...): -want error, +got error:\n%s", diff) + } + if rec.called != 0 { + t.Error("builder must not be called when region is empty") + } + }) + + t.Run("BuilderError", func(t *testing.T) { + creds := fullCreds() + rec := &builderCall{} + + err := InjectToken(ctx, creds, "eu-west-1", noCreds, recordingBuilder("", errBoom, rec)) + if diff := cmp.Diff(errors.Wrap(errBoom, errBuildToken), err, test.EquateErrors()); diff != "" { + t.Errorf("InjectToken(...): -want error, +got error:\n%s", diff) + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("password must not be set when token generation fails") + } + }) +} diff --git a/pkg/clients/postgresql/postgresql_test.go b/pkg/clients/postgresql/postgresql_test.go index a2056b91..999481f1 100644 --- a/pkg/clients/postgresql/postgresql_test.go +++ b/pkg/clients/postgresql/postgresql_test.go @@ -1,6 +1,7 @@ package postgresql import ( + "net/url" "testing" ) @@ -17,3 +18,22 @@ func TestDSNURLEscaping(t *testing.T) { t.Errorf("DSN string did not match expected output with userinfo URL encoded") } } + +func TestDSNIAMTokenEscaping(t *testing.T) { + token := "db.example.com:5432/?Action=connect&DBUser=crossplane_admin&X-Amz-Algorithm=AWS4-HMAC-SHA256" + dsn := DSN("crossplane_admin", token, "db.example.com", "5432", "postgres", "verify-full") + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse DSN: %v", err) + } + pass, ok := u.User.Password() + if !ok || pass != token { + t.Errorf("password: want the IAM token, got %q", pass) + } + if u.Query().Get("sslmode") != "verify-full" { + t.Errorf("sslmode: want verify-full, got %q", u.Query().Get("sslmode")) + } + if u.Query().Get("sslrootcert") != "" { + t.Errorf("sslrootcert must stay unset so a registered CA is used, got %q", u.Query().Get("sslrootcert")) + } +} diff --git a/pkg/clients/postgresql/tls.go b/pkg/clients/postgresql/tls.go new file mode 100644 index 00000000..55d6c554 --- /dev/null +++ b/pkg/clients/postgresql/tls.go @@ -0,0 +1,93 @@ +/* +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" + + "github.com/pkg/errors" +) + +const ( + sslModeVerifyCA = "verify-ca" + sslModeVerifyFull = "verify-full" + + errParseCA = "cannot parse CA bundle from tlsConfig.caCert" +) + +// UsesCustomCA reports whether sslMode verifies the server certificate and +// therefore needs the CA bundle from tlsConfig.caCert. +func UsesCustomCA(sslMode string) bool { + return sslMode == sslModeVerifyCA || sslMode == sslModeVerifyFull +} + +// TLSConfig builds a client TLS configuration from a PEM bundle. The bundle +// may contain multiple CERTIFICATE blocks (roots and intermediates); every +// block is added to RootCAs. +// +// verify-full leaves certificate verification enabled and sets ServerName so +// crypto/tls checks the chain and the hostname. verify-ca skips the hostname +// check and verifies the chain against the bundle. +func TLSConfig(sslMode, serverName string, pemBytes []byte) (*tls.Config, error) { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, errors.New(errParseCA) + } + + cfg := &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + } + switch sslMode { + case sslModeVerifyFull: + cfg.ServerName = serverName + case sslModeVerifyCA: + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate = verifyChainOnly(pool) + default: + return nil, errors.Errorf("sslMode %q does not use a custom CA", sslMode) + } + return cfg, nil +} + +// verifyChainOnly checks that the peer certificate chains to pool. It does not +// check the hostname. crypto/tls calls it when InsecureSkipVerify is set. +func verifyChainOnly(pool *x509.CertPool) func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return errors.New("server presented no certificate") + } + certs := make([]*x509.Certificate, len(rawCerts)) + for i, asn1Data := range rawCerts { + cert, err := x509.ParseCertificate(asn1Data) + if err != nil { + return errors.Wrap(err, "cannot parse server certificate") + } + certs[i] = cert + } + opts := x509.VerifyOptions{ + Roots: pool, + Intermediates: x509.NewCertPool(), + } + for _, cert := range certs[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := certs[0].Verify(opts) + return err + } +} diff --git a/pkg/clients/postgresql/tls_test.go b/pkg/clients/postgresql/tls_test.go new file mode 100644 index 00000000..85c19eec --- /dev/null +++ b/pkg/clients/postgresql/tls_test.go @@ -0,0 +1,125 @@ +/* +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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +func testCertPEM(t *testing.T, name string) ([]byte, *x509.Certificate) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), cert +} + +func TestTLSConfig(t *testing.T) { + pemA, certA := testCertPEM(t, "a.example.com") + pemB, certB := testCertPEM(t, "b.example.com") + bundle := append(append([]byte{}, pemA...), pemB...) + + t.Run("VerifyFull", func(t *testing.T) { + cfg, err := TLSConfig(sslModeVerifyFull, "db.example.rds.amazonaws.com", pemA) + if err != nil { + t.Fatalf("TLSConfig: %v", err) + } + if cfg.InsecureSkipVerify { + t.Error("verify-full must leave certificate verification enabled") + } + if cfg.ServerName != "db.example.rds.amazonaws.com" { + t.Errorf("ServerName: want db.example.rds.amazonaws.com, got %q", cfg.ServerName) + } + if cfg.VerifyPeerCertificate != nil { + t.Error("verify-full uses crypto/tls verification, not a custom callback") + } + }) + + t.Run("VerifyCAChainOnly", func(t *testing.T) { + cfg, err := TLSConfig(sslModeVerifyCA, "db.example.rds.amazonaws.com", pemA) + if err != nil { + t.Fatalf("TLSConfig: %v", err) + } + if !cfg.InsecureSkipVerify { + t.Error("verify-ca skips the hostname check") + } + if cfg.VerifyPeerCertificate == nil { + t.Fatal("verify-ca must check the certificate chain") + } + if err := cfg.VerifyPeerCertificate([][]byte{certA.Raw}, nil); err != nil { + t.Errorf("certificate in the bundle should verify: %v", err) + } + if err := cfg.VerifyPeerCertificate([][]byte{certB.Raw}, nil); err == nil { + t.Error("certificate outside the bundle must fail verification") + } + }) + + t.Run("BundleLoadsEveryCertificate", func(t *testing.T) { + cfg, err := TLSConfig(sslModeVerifyCA, "db.example.com", bundle) + if err != nil { + t.Fatalf("TLSConfig: %v", err) + } + if err := cfg.VerifyPeerCertificate([][]byte{certA.Raw}, nil); err != nil { + t.Errorf("first certificate in the bundle should verify: %v", err) + } + if err := cfg.VerifyPeerCertificate([][]byte{certB.Raw}, nil); err != nil { + t.Errorf("second certificate in the bundle should verify: %v", err) + } + }) + + t.Run("NotPEM", func(t *testing.T) { + if _, err := TLSConfig(sslModeVerifyFull, "db.example.com", []byte("not a certificate")); err == nil { + t.Fatal("expected an error for a non-PEM secret") + } + }) +} + +func TestUsesCustomCA(t *testing.T) { + if UsesCustomCA("require") || UsesCustomCA("disable") || UsesCustomCA("") { + t.Error("require, disable, and an empty mode do not use a custom CA") + } + if !UsesCustomCA(sslModeVerifyCA) || !UsesCustomCA(sslModeVerifyFull) { + t.Error("verify-ca and verify-full use a custom CA") + } +} diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 362d7de7..64bb5d15 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -41,9 +41,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -118,9 +119,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -132,7 +130,11 @@ 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, err := connect.Prepare(ctx, c.kube, pgconn.Input(pc, secretData)) + if err != nil { + return nil, err + } + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode)}, 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..c89a8440 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -33,6 +33,7 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) type mockDB struct { @@ -73,6 +74,13 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + origInject := connect.InjectIAM + connect.InjectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + t.Cleanup(func() { connect.InjectIAM = origInject }) + type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error @@ -168,6 +176,48 @@ func TestConnect(t *testing.T) { }, want: errors.Wrap(errBoom, errGetSecret), }, + "SuccessAWSIAMAuth": { + reason: "When source is AWSIAMAuth, a token is injected as the password and sslMode is left unchanged.", + 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"} + o.Spec.SSLMode = ptr("verify-full") + 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(c map[string][]byte, _ string, sslmode string) xsql.DB { + if sslmode != "verify-full" { + t.Errorf("expected sslmode verify-full, got %q", sslmode) + } + if got := string(c[xpv2.CredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("expected injected token as password, got %q", got) + } + return mockDB{} + }, + }, + args: args{ + ctx: context.Background(), + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{Name: "example"}, + }, + }, + }, + }, + }, } for name, tc := range cases { diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index 004ab438..ac9f2975 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -39,9 +39,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -114,9 +115,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -135,7 +133,11 @@ 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, err := connect.Prepare(ctx, c.kube, pgconn.Input(pc, secretData)) + if err != nil { + return nil, err + } + return &external{db: c.newDB(secretData, database, sslMode)}, nil } type external struct { diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index e76665bd..9958ebd1 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -37,9 +37,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -108,9 +109,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -122,14 +120,18 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + sslMode, err := connect.Prepare(ctx, c.kube, pgconn.Input(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)}, 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)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index de5948f6..365cd0fb 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -40,9 +40,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -116,9 +117,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -129,7 +127,11 @@ 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, err := connect.Prepare(ctx, c.kube, pgconn.Input(pc, secretData)) + if err != nil { + return nil, err + } + xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode) // A server that cannot report its version is not a reason to fail: only // table grants are version dependent, and ExpandPrivilegesWithVersion diff --git a/pkg/controller/cluster/postgresql/pgconn/pgconn.go b/pkg/controller/cluster/postgresql/pgconn/pgconn.go new file mode 100644 index 00000000..6a42eeeb --- /dev/null +++ b/pkg/controller/cluster/postgresql/pgconn/pgconn.go @@ -0,0 +1,40 @@ +/* +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 pgconn builds the connection input for a cluster-scoped PostgreSQL ProviderConfig. +package pgconn + +import ( + "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" +) + +// Input returns the connect input for pc. creds is the remapped connection secret. +func Input(pc *v1alpha1.ProviderConfig, creds map[string][]byte) connect.Input { + var ca *connect.CASecretRef + if pc.Spec.TLSConfig != nil { + ref := pc.Spec.TLSConfig.CACert.SecretRef + ca = connect.CA(ref.Name, ref.Namespace, ref.Key) + } + return connect.Input{ + Source: string(pc.Spec.Credentials.Source), + Region: pc.Spec.Credentials.Region, + SSLMode: pc.Spec.SSLMode, + CA: ca, + RegisterKey: "cluster-" + pc.GetName(), + Creds: creds, + } +} diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 2da5d98f..e4875a8c 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -44,9 +44,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -120,9 +121,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -134,8 +132,12 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + sslMode, err := connect.Prepare(ctx, c.kube, pgconn.Input(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), kube: c.kube, }, nil } diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 7d616c9c..a352a5d9 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -39,9 +39,10 @@ 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/pgconn" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" ) const ( @@ -112,9 +113,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -130,7 +128,11 @@ 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, err := connect.Prepare(ctx, c.kube, pgconn.Input(pc, secretData)) + if err != nil { + return nil, err + } + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode)}, nil } var _ managed.TypedExternalClient[*v1alpha1.Schema] = &external{} diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index 93652601..00dc8e56 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -11,6 +11,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" provErrors "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -22,72 +23,128 @@ type ProviderInfo struct { SSLMode *string } -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 - ) - - switch mg.GetProviderConfigReference().Kind { - case v1alpha1.ProviderConfigKind: - providerConfig := &v1alpha1.ProviderConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: mg.GetProviderConfigReference().Name, - Namespace: mg.GetNamespace(), - }, - } - - if err := kube.Get(ctx, client.ObjectKeyFromObject(providerConfig), providerConfig); err != nil { - return ProviderInfo{}, provErrors.GetProviderConfigError(err) - } - - secretKey = &client.ObjectKey{ - Name: providerConfig.Spec.Credentials.ConnectionSecretRef.Name, - Namespace: mg.GetNamespace(), - } +type resolvedConfig struct { + secretKey *client.ObjectKey + defaultDatabase string + sslMode *string + keyMapping map[string]string + source string + region *string + ca *connect.CASecretRef + defaultNamespace string + registerKey string +} - defaultDatabase = providerConfig.Spec.DefaultDatabase - sslMode = providerConfig.Spec.SSLMode - keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() - case v1alpha1.ClusterProviderConfigKind: - clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: mg.GetProviderConfigReference().Name, - }, - } - - if err := kube.Get(ctx, client.ObjectKeyFromObject(clusterProviderConfig), clusterProviderConfig); err != nil { - return ProviderInfo{}, provErrors.GetClusterProviderConfigError(err) - } - - secretKey = &client.ObjectKey{ - Name: clusterProviderConfig.Spec.Credentials.ConnectionSecretRef.Name, - Namespace: clusterProviderConfig.Spec.Credentials.ConnectionSecretRef.Namespace, - } - - defaultDatabase = clusterProviderConfig.Spec.DefaultDatabase - sslMode = clusterProviderConfig.Spec.SSLMode - keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() - default: - return ProviderInfo{}, provErrors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) +func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { + resolved, err := resolveProviderConfig(ctx, kube, mg) + if err != nil { + return ProviderInfo{}, err } + secretKey := resolved.secretKey if secretKey.Name == "" || secretKey.Namespace == "" { return ProviderInfo{}, provErrors.MissingSecretRefError() } s := &corev1.Secret{} - err := kube.Get(ctx, *secretKey, s) + err = kube.Get(ctx, *secretKey, s) if err != nil { return ProviderInfo{}, provErrors.GetSecretError(err) } + secretData := xsql.RemapCredentialKeys(s.Data, resolved.keyMapping) + effectiveSSL, err := connect.Prepare(ctx, kube, connect.Input{ + Source: resolved.source, + Region: resolved.region, + SSLMode: resolved.sslMode, + CA: resolved.ca, + DefaultNamespace: resolved.defaultNamespace, + RegisterKey: resolved.registerKey, + Creds: secretData, + }) + if err != nil { + return ProviderInfo{}, err + } + var sslMode *string + if effectiveSSL != "" { + sslMode = &effectiveSSL + } + return ProviderInfo{ ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), - DefaultDatabase: defaultDatabase, + SecretData: secretData, + DefaultDatabase: resolved.defaultDatabase, SSLMode: sslMode, }, nil } + +func resolveProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { + switch mg.GetProviderConfigReference().Kind { + case v1alpha1.ProviderConfigKind: + return resolveNamespaced(ctx, kube, mg) + case v1alpha1.ClusterProviderConfigKind: + return resolveCluster(ctx, kube, mg) + default: + return resolvedConfig{}, provErrors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) + } +} + +func resolveNamespaced(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { + providerConfig := &v1alpha1.ProviderConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.GetProviderConfigReference().Name, + Namespace: mg.GetNamespace(), + }, + } + if err := kube.Get(ctx, client.ObjectKeyFromObject(providerConfig), providerConfig); err != nil { + return resolvedConfig{}, provErrors.GetProviderConfigError(err) + } + + resolved := resolvedConfig{ + secretKey: &client.ObjectKey{ + Name: providerConfig.Spec.Credentials.ConnectionSecretRef.Name, + Namespace: mg.GetNamespace(), + }, + defaultDatabase: providerConfig.Spec.DefaultDatabase, + sslMode: providerConfig.Spec.SSLMode, + keyMapping: providerConfig.Spec.Credentials.SecretKeyMapping.ToMap(), + source: string(providerConfig.Spec.Credentials.Source), + region: providerConfig.Spec.Credentials.Region, + defaultNamespace: mg.GetNamespace(), + registerKey: "ns-" + mg.GetNamespace() + "-" + providerConfig.GetName(), + } + if providerConfig.Spec.TLSConfig != nil { + ref := providerConfig.Spec.TLSConfig.CACert.SecretRef + resolved.ca = connect.CA(ref.Name, ref.Namespace, ref.Key) + } + return resolved, nil +} + +func resolveCluster(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { + providerConfig := &v1alpha1.ClusterProviderConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.GetProviderConfigReference().Name, + }, + } + if err := kube.Get(ctx, client.ObjectKeyFromObject(providerConfig), providerConfig); err != nil { + return resolvedConfig{}, provErrors.GetClusterProviderConfigError(err) + } + + resolved := resolvedConfig{ + secretKey: &client.ObjectKey{ + Name: providerConfig.Spec.Credentials.ConnectionSecretRef.Name, + Namespace: providerConfig.Spec.Credentials.ConnectionSecretRef.Namespace, + }, + defaultDatabase: providerConfig.Spec.DefaultDatabase, + sslMode: providerConfig.Spec.SSLMode, + keyMapping: providerConfig.Spec.Credentials.SecretKeyMapping.ToMap(), + source: string(providerConfig.Spec.Credentials.Source), + region: providerConfig.Spec.Credentials.Region, + registerKey: "cpc-" + providerConfig.GetName(), + } + if providerConfig.Spec.TLSConfig != nil { + ref := providerConfig.Spec.TLSConfig.CACert.SecretRef + resolved.ca = connect.CA(ref.Name, ref.Namespace, ref.Key) + } + return resolved, 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..e6468dcd --- /dev/null +++ b/pkg/controller/namespaced/postgresql/provider/provider_test.go @@ -0,0 +1,184 @@ +/* +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" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + 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" + "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/connect" +) + +func TestGetProviderConfigAWSIAMAuth(t *testing.T) { + orig := connect.InjectIAM + connect.InjectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + t.Cleanup(func() { connect.InjectIAM = orig }) + + conn := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } + + for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} { + t.Run("ProviderConfig/"+mode, func(t *testing.T) { + info := getInfo(t, &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.LocalSecretReference{Name: "s"} + o.Spec.SSLMode = ptr(mode) + case *corev1.Secret: + o.Data = conn + } + return nil + }), + }, providerConfig("default", v1alpha1.ProviderConfigKind)) + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("password: want iam-token, got %q", got) + } + if info.SSLMode == nil || *info.SSLMode != mode { + t.Errorf("sslMode: want %s, got %v", mode, info.SSLMode) + } + }) + + t.Run("ClusterProviderConfig/"+mode, func(t *testing.T) { + info := getInfo(t, &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ClusterProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Namespace: "crossplane-system", Name: "s"} + o.Spec.SSLMode = ptr(mode) + case *corev1.Secret: + o.Data = conn + } + return nil + }), + }, providerConfig("default", v1alpha1.ClusterProviderConfigKind)) + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("password: want iam-token, got %q", got) + } + if info.SSLMode == nil || *info.SSLMode != mode { + t.Errorf("sslMode: want %s, got %v", mode, info.SSLMode) + } + }) + } +} + +func TestGetProviderConfigCA(t *testing.T) { + pemBytes := certPEM(t) + secrets := 0 + info := getInfo(t, &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + o.Spec.SSLMode = ptr("verify-full") + o.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: v1alpha1.TLSSecretRef{ + Name: "amazon-rds-ca", + Key: "global-bundle.pem", + }}} + case *corev1.Secret: + secrets++ + if secrets == 1 { + 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("secret"), + } + return nil + } + o.Data = map[string][]byte{"global-bundle.pem": pemBytes} + } + return nil + }), + }, providerConfig("default", v1alpha1.ProviderConfigKind)) + if info.SSLMode == nil || !strings.HasPrefix(*info.SSLMode, "pqgo-") { + t.Errorf("sslMode: want pqgo-..., got %v", info.SSLMode) + } +} + +func providerConfig(namespace, kind string) *v1alpha1.Database { + return &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: v1alpha1.DatabaseSpec{ + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{ + Kind: kind, + Name: "aurora", + }, + }, + }, + } +} + +func getInfo(t *testing.T, kube client.Client, mg *v1alpha1.Database) ProviderInfo { + t.Helper() + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatalf("GetProviderConfig: %v", err) + } + return info +} + +func ptr(s string) *string { return &s } + +func certPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "rds.example.com"}, + 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, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/pkg/controller/postgresql/connect/connect.go b/pkg/controller/postgresql/connect/connect.go new file mode 100644 index 00000000..c49382bf --- /dev/null +++ b/pkg/controller/postgresql/connect/connect.go @@ -0,0 +1,148 @@ +/* +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 a PostgreSQL connection for a ProviderConfig. +// When the credentials source is AWSIAMAuth it injects a fresh RDS IAM token. +// When tlsConfig.caCert is set for verify-ca or verify-full, it registers that +// CA bundle with lib/pq. Other sslMode values are returned unchanged. +package connect + +import ( + "context" + + "github.com/lib/pq" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" +) + +const ( + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errIncompleteCARef = "tlsConfig.caCert.secretRef requires name, namespace, and key" + errGetCACert = "cannot get tlsConfig.caCert secret" + errRegisterTLS = "cannot register PostgreSQL TLS configuration" +) + +// InjectIAM generates and injects an AWS IAM auth token. Tests replace it. +var InjectIAM = awsiam.Inject + +// CASecretRef selects the PEM bundle used as the server CA. +type CASecretRef struct { + Name string + Namespace string + Key string +} + +// CA returns a secret ref when name is set. +func CA(name, namespace, key string) *CASecretRef { + if name == "" { + return nil + } + return &CASecretRef{Name: name, Namespace: namespace, Key: key} +} + +// Input is everything Prepare needs from a ProviderConfig and its connection secret. +type Input struct { + Source string + Region *string + SSLMode *string + CA *CASecretRef + DefaultNamespace string + RegisterKey string + Creds map[string][]byte +} + +// Prepare injects an IAM token when Source is AWSIAMAuth and, for verify-ca +// and verify-full with a CA secret, registers that bundle. The returned +// string is the sslmode to pass to the PostgreSQL client. It is pqgo- +// when a custom CA was registered, and the configured sslMode otherwise. +// An unset sslMode is returned as an empty string. +// +// An empty CA namespace uses DefaultNamespace. That is how a namespaced +// ProviderConfig resolves a secret in its own namespace. +func Prepare(ctx context.Context, kube client.Client, in Input) (string, error) { + mode := sslModeOf(in.SSLMode) + if err := injectIAM(ctx, in); err != nil { + return "", err + } + if in.CA == nil || !postgresql.UsesCustomCA(mode) { + return mode, nil + } + return registerCA(ctx, kube, in, mode) +} + +func sslModeOf(mode *string) string { + if mode == nil { + return "" + } + return *mode +} + +func injectIAM(ctx context.Context, in Input) error { + if in.Source != awsiam.SourceAWSIAMAuth { + return nil + } + return errors.Wrap(InjectIAM(ctx, in.Region, in.Creds), errGenerateIAMToken) +} + +func registerCA(ctx context.Context, kube client.Client, in Input, mode string) (string, error) { + ns, err := caNamespace(in) + if err != nil { + return "", err + } + pemBytes, err := secretKey(ctx, kube, ns, in.CA.Name, in.CA.Key) + if err != nil { + return "", errors.Wrap(err, errGetCACert) + } + endpoint := string(in.Creds[xpv2.CredentialsSecretEndpointKey]) + tlsCfg, err := postgresql.TLSConfig(mode, endpoint, pemBytes) + if err != nil { + return "", err + } + if err := pq.RegisterTLSConfig(in.RegisterKey, tlsCfg); err != nil { + return "", errors.Wrap(err, errRegisterTLS) + } + return "pqgo-" + in.RegisterKey, nil +} + +func caNamespace(in Input) (string, error) { + ns := in.CA.Namespace + if ns == "" { + ns = in.DefaultNamespace + } + if ns == "" || in.CA.Key == "" || in.RegisterKey == "" { + return "", errors.New(errIncompleteCARef) + } + return ns, nil +} + +func secretKey(ctx context.Context, kube client.Client, namespace, name, key string) ([]byte, error) { + secret := &corev1.Secret{} + if err := kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret); err != nil { + return nil, err + } + data, ok := secret.Data[key] + if !ok { + return nil, errors.Errorf("key %q not found in Secret %s/%s", key, namespace, name) + } + return data, nil +} diff --git a/pkg/controller/postgresql/connect/connect_test.go b/pkg/controller/postgresql/connect/connect_test.go new file mode 100644 index 00000000..52889f74 --- /dev/null +++ b/pkg/controller/postgresql/connect/connect_test.go @@ -0,0 +1,167 @@ +/* +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" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/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" +) + +func certPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "rds.example.com"}, + 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, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func creds() map[string][]byte { + return map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } +} + +func TestPrepareSSLModePassthrough(t *testing.T) { + orig := InjectIAM + InjectIAM = func(_ context.Context, _ *string, c map[string][]byte) error { + c[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + t.Cleanup(func() { InjectIAM = orig }) + + kube := &test.MockClient{MockGet: test.NewMockGetFn(errors.New("secret must not be read"))} + for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} { + got, err := Prepare(context.Background(), kube, Input{ + Source: "AWSIAMAuth", + SSLMode: &mode, + Creds: creds(), + }) + if err != nil { + t.Fatalf("Prepare(%s): %v", mode, err) + } + if got != mode { + t.Errorf("Prepare(%s): want %q, got %q", mode, mode, got) + } + } + + got, err := Prepare(context.Background(), kube, Input{Source: "AWSIAMAuth", Creds: creds()}) + if err != nil { + t.Fatalf("Prepare(unset): %v", err) + } + if got != "" { + t.Errorf("unset sslMode: want empty, got %q", got) + } + if len(creds()[xpv2.CredentialsSecretPasswordKey]) != 0 { + t.Fatal("fixture creds must start without a password") + } +} + +func TestPrepareRegistersCA(t *testing.T) { + pemBytes := certPEM(t) + kube := &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + secret, ok := obj.(*corev1.Secret) + if !ok { + t.Fatalf("got %T, want Secret", obj) + } + secret.Data = map[string][]byte{"global-bundle.pem": pemBytes} + return nil + }), + } + mode := "verify-full" + got, err := Prepare(context.Background(), kube, Input{ + SSLMode: &mode, + CA: CA("amazon-rds-ca", "", "global-bundle.pem"), + DefaultNamespace: "crossplane-system", + RegisterKey: "ns-crossplane-system-aurora", + Creds: creds(), + }) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if got != "pqgo-ns-crossplane-system-aurora" { + t.Errorf("sslmode: want pqgo-ns-crossplane-system-aurora, got %q", got) + } +} + +func TestPrepareRequireIgnoresCA(t *testing.T) { + kube := &test.MockClient{MockGet: test.NewMockGetFn(errors.New("secret must not be read"))} + mode := "require" + got, err := Prepare(context.Background(), kube, Input{ + SSLMode: &mode, + CA: CA("amazon-rds-ca", "ns", "global-bundle.pem"), + RegisterKey: "cluster-aurora", + Creds: creds(), + }) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if got != "require" { + t.Errorf("sslmode: want require, got %q", got) + } +} + +func TestPrepareRejectsNonPEM(t *testing.T) { + kube := &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + obj.(*corev1.Secret).Data = map[string][]byte{"ca.pem": []byte("nope")} + return nil + }), + } + mode := "verify-full" + _, err := Prepare(context.Background(), kube, Input{ + SSLMode: &mode, + CA: CA("amazon-rds-ca", "ns", "ca.pem"), + RegisterKey: "cluster-aurora", + Creds: creds(), + }) + if err == nil || !strings.Contains(err.Error(), "cannot parse CA bundle") { + t.Fatalf("want a CA parse error, got %v", err) + } +} From b4a455a9426626a624ee0f7c6350bb44c91276c2 Mon Sep 17 00:00:00 2001 From: krogon-bt <218959328+krogon-bt@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:40:19 +0200 Subject: [PATCH 2/2] feat(mysql): add Aurora IAM auth (#2) Mint a fresh RDS IAM token on each connect and send it with the cleartext plugin over the TLS mode already set on the ProviderConfig. tls=custom can verify Aurora with only the RDS CA secret. Signed-off-by: krogon-bt <218959328+krogon-bt@users.noreply.github.com> --- apis/cluster/mysql/v1alpha1/provider_types.go | 28 ++- .../mysql/v1alpha1/zz_generated.deepcopy.go | 5 + .../mysql/v1alpha1/cluster_provider_types.go | 23 +- .../mysql/v1alpha1/provider_types.go | 28 ++- .../mysql/v1alpha1/zz_generated.deepcopy.go | 10 + docs/aws-iam-auth.md | 36 +++- examples/cluster/mysql/config_iam.yaml | 35 ++++ .../namespaced/mysql/clusterconfig_iam.yaml | 19 ++ examples/namespaced/mysql/config.yaml | 3 +- examples/namespaced/mysql/config_iam.yaml | 25 +++ ...sql.sql.crossplane.io_providerconfigs.yaml | 21 +- ....crossplane.io_clusterproviderconfigs.yaml | 21 +- ...l.sql.m.crossplane.io_providerconfigs.yaml | 21 +- pkg/clients/awsiam/awsiam.go | 4 + pkg/clients/mysql/iam.go | 20 ++ pkg/clients/mysql/mysql.go | 42 ++-- pkg/clients/mysql/mysql_test.go | 38 +++- .../cluster/mysql/database/reconciler.go | 14 +- .../cluster/mysql/database/reconciler_test.go | 101 ++++++++- .../cluster/mysql/grant/reconciler.go | 14 +- .../cluster/mysql/grant/reconciler_test.go | 2 +- pkg/controller/cluster/mysql/tls/tls.go | 82 +++++--- pkg/controller/cluster/mysql/tls/tls_test.go | 198 ++++++++++++++++++ .../cluster/mysql/user/reconciler.go | 14 +- .../cluster/mysql/user/reconciler_test.go | 2 +- .../namespaced/mysql/database/reconciler.go | 6 +- .../mysql/database/reconciler_test.go | 2 +- .../namespaced/mysql/grant/reconciler.go | 6 +- .../namespaced/mysql/grant/reconciler_test.go | 2 +- .../namespaced/mysql/provider/provider.go | 153 +++++++++----- .../mysql/provider/provider_test.go | 137 ++++++++++++ pkg/controller/namespaced/mysql/tls/tls.go | 90 +++++--- .../namespaced/mysql/tls/tls_test.go | 140 +++++++++++++ .../namespaced/mysql/user/reconciler.go | 6 +- .../namespaced/mysql/user/reconciler_test.go | 2 +- 35 files changed, 1141 insertions(+), 209 deletions(-) create mode 100644 examples/cluster/mysql/config_iam.yaml create mode 100644 examples/namespaced/mysql/clusterconfig_iam.yaml create mode 100644 examples/namespaced/mysql/config_iam.yaml create mode 100644 pkg/clients/mysql/iam.go create mode 100644 pkg/controller/cluster/mysql/tls/tls_test.go create mode 100644 pkg/controller/namespaced/mysql/provider/provider_test.go create mode 100644 pkg/controller/namespaced/mysql/tls/tls_test.go diff --git a/apis/cluster/mysql/v1alpha1/provider_types.go b/apis/cluster/mysql/v1alpha1/provider_types.go index a6dc93fa..6cadf0ff 100644 --- a/apis/cluster/mysql/v1alpha1/provider_types.go +++ b/apis/cluster/mysql/v1alpha1/provider_types.go @@ -26,13 +26,14 @@ import ( type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` - // tls=true enables TLS / SSL encrypted connection to the server. - // Use skip-verify if you want to use a self-signed or invalid certificate (server side) - // or use preferred to use TLS only when advertised by the server. This is similar - // to skip-verify, but additionally allows a fallback to a connection which is - // not encrypted. Neither skip-verify nor preferred add any reliable security. - // Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. - // +kubebuilder:validation:Enum="true";skip-verify;preferred;custom + // tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + // false is a boolean and this string field rejects it. tls=true enables an + // encrypted connection verified with the pod trust store. Use skip-verify for + // a self-signed or invalid server certificate, or preferred to use TLS only + // when the server advertises it. preferred also allows a plaintext fallback. + // Neither skip-verify nor preferred verifies the server. Alternatively, set + // tls=custom and provide a CA via tlsConfig. An omitted value means preferred. + // +kubebuilder:validation:Enum="false";"true";skip-verify;preferred;custom // +optional TLS *string `json:"tls"` @@ -59,12 +60,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 +83,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/namespaced/mysql/v1alpha1/cluster_provider_types.go b/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go index 5e1f1e2e..5a0010c1 100644 --- a/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go @@ -27,13 +27,14 @@ type ClusterProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ClusterProviderCredentials `json:"credentials"` - // tls=true enables TLS / SSL encrypted connection to the server. - // Use skip-verify if you want to use a self-signed or invalid certificate (server side) - // or use preferred to use TLS only when advertised by the server. This is similar - // to skip-verify, but additionally allows a fallback to a connection which is - // not encrypted. Neither skip-verify nor preferred add any reliable security. - // Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. - // +kubebuilder:validation:Enum="true";skip-verify;preferred;custom + // tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + // false is a boolean and this string field rejects it. tls=true enables an + // encrypted connection verified with the pod trust store. Use skip-verify for + // a self-signed or invalid server certificate, or preferred to use TLS only + // when the server advertises it. preferred also allows a plaintext fallback. + // Neither skip-verify nor preferred verifies the server. Alternatively, set + // tls=custom and provide a CA via tlsConfig. An omitted value means preferred. + // +kubebuilder:validation:Enum="false";"true";skip-verify;preferred;custom // +optional TLS *string `json:"tls"` @@ -45,7 +46,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 +59,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..29d723cf 100644 --- a/apis/namespaced/mysql/v1alpha1/provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/provider_types.go @@ -26,13 +26,14 @@ import ( type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` - // tls=true enables TLS / SSL encrypted connection to the server. - // Use skip-verify if you want to use a self-signed or invalid certificate (server side) - // or use preferred to use TLS only when advertised by the server. This is similar - // to skip-verify, but additionally allows a fallback to a connection which is - // not encrypted. Neither skip-verify nor preferred add any reliable security. - // Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. - // +kubebuilder:validation:Enum="true";skip-verify;preferred;custom + // tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + // false is a boolean and this string field rejects it. tls=true enables an + // encrypted connection verified with the pod trust store. Use skip-verify for + // a self-signed or invalid server certificate, or preferred to use TLS only + // when the server advertises it. preferred also allows a plaintext fallback. + // Neither skip-verify nor preferred verifies the server. Alternatively, set + // tls=custom and provide a CA via tlsConfig. An omitted value means preferred. + // +kubebuilder:validation:Enum="false";"true";skip-verify;preferred;custom // +optional TLS *string `json:"tls"` @@ -61,12 +62,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 +85,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/docs/aws-iam-auth.md b/docs/aws-iam-auth.md index 291832a2..59ff0abb 100644 --- a/docs/aws-iam-auth.md +++ b/docs/aws-iam-auth.md @@ -1,6 +1,6 @@ # AWS IAM database authentication -provider-sql can authenticate to Amazon Aurora PostgreSQL with [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). On each connect it generates a short-lived token from the AWS SDK default credential chain and uses that token as the database password. On EKS, [Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) supplies those credentials. The token is not cached, so a provider process that runs longer than the 15-minute token lifetime keeps reconciling. A single reconcile that itself lasts longer than 15 minutes and then opens another connection can fail. Normal SQL reconciles are much shorter. +provider-sql can authenticate to Amazon Aurora PostgreSQL and Aurora MySQL with [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). On each connect it generates a short-lived token from the AWS SDK default credential chain and uses that token as the database password. On EKS, [Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) supplies those credentials. The token is not cached, so a provider process that runs longer than the 15-minute token lifetime keeps reconciling. A single reconcile that itself lasts longer than 15 minutes and then opens another connection can fail. Normal SQL reconciles are much shorter. ```yaml spec: @@ -49,3 +49,37 @@ Examples: - `examples/cluster/postgresql/config_iam.yaml` - `examples/namespaced/postgresql/config_iam.yaml` - `examples/namespaced/postgresql/clusterconfig_iam.yaml` + +## MySQL + +The same token is used for Aurora MySQL. `tls` is left as set. `allowCleartextPasswords=true` is added only for `AWSIAMAuth`, because the AWS plugin sends the token with the MySQL cleartext client plugin. TLS still carries it. A password login does not set that flag. + +```yaml +spec: + tls: custom + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + name: aurora-conn +``` + +`tls: "false"` is plaintext (`--ssl-mode=DISABLED`). Quote it. An unquoted `false` is a boolean and the string field rejects it. Omitting `tls` still means `preferred`. Aurora rejects a plaintext IAM login. + +`tls: custom` verifies the server with `tlsConfig.caCert`. The secret key is the same RDS global bundle. `clientCert` and `clientKey` are optional and must be set together; IAM database auth has no client certificate. The driver sets the hostname check from the endpoint. `tls: true` still uses the pod trust store, which does not contain the RDS CA. + +A `User` can set `authenticationPlugin.name: AWSAuthenticationPlugin` and `authString: RDS`. The master login that creates that user still uses a password `ProviderConfig`. + +Examples: + +- `examples/cluster/mysql/config_iam.yaml` +- `examples/namespaced/mysql/config_iam.yaml` +- `examples/namespaced/mysql/clusterconfig_iam.yaml` +- `examples/cluster/mysql/user_iam_auth.yaml` +- `examples/namespaced/mysql/user_iam_auth.yaml` diff --git a/examples/cluster/mysql/config_iam.yaml b/examples/cluster/mysql/config_iam.yaml new file mode 100644 index 00000000..697fea37 --- /dev/null +++ b/examples/cluster/mysql/config_iam.yaml @@ -0,0 +1,35 @@ +--- +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + tls: custom + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn + +# The connection secret holds endpoint, port, and username. The provider +# generates a short-lived IAM token and uses it as the password. +# The CA secret holds the Amazon RDS global bundle, one PEM containing the +# root and intermediate certificates: +# https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem +# --- +# 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/namespaced/mysql/clusterconfig_iam.yaml b/examples/namespaced/mysql/clusterconfig_iam.yaml new file mode 100644 index 00000000..3b0a505c --- /dev/null +++ b/examples/namespaced/mysql/clusterconfig_iam.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ClusterProviderConfig +metadata: + name: aurora-iam +spec: + tls: custom + tlsConfig: + caCert: + secretRef: + namespace: crossplane-system + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn diff --git a/examples/namespaced/mysql/config.yaml b/examples/namespaced/mysql/config.yaml index 917e8432..5491ef2b 100644 --- a/examples/namespaced/mysql/config.yaml +++ b/examples/namespaced/mysql/config.yaml @@ -9,5 +9,6 @@ spec: source: MySQLConnectionSecret connectionSecretRef: name: db-conn - # tls one of preferred(default), skip-verify, true, or custom + # tls one of "false", preferred (default), skip-verify, true, or custom. + # Quote false: tls: "false" tls: preferred diff --git a/examples/namespaced/mysql/config_iam.yaml b/examples/namespaced/mysql/config_iam.yaml new file mode 100644 index 00000000..7823daaf --- /dev/null +++ b/examples/namespaced/mysql/config_iam.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + tls: custom + tlsConfig: + caCert: + secretRef: + namespace: default + name: amazon-rds-ca + key: global-bundle.pem + credentials: + source: AWSIAMAuth + region: us-east-1 + connectionSecretRef: + name: aurora-conn + +# amazon-rds-ca and aurora-conn live in the ProviderConfig namespace. +# aurora-conn holds endpoint, port, and username. No password. +# amazon-rds-ca key global-bundle.pem is the Amazon RDS global bundle. +# MySQL tls secret references still set namespace, including on a namespaced +# ProviderConfig. diff --git a/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml index 5b7c0366..c96db511 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,19 +103,22 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source type: object tls: description: |- - tls=true enables TLS / SSL encrypted connection to the server. - Use skip-verify if you want to use a self-signed or invalid certificate (server side) - or use preferred to use TLS only when advertised by the server. This is similar - to skip-verify, but additionally allows a fallback to a connection which is - not encrypted. Neither skip-verify nor preferred add any reliable security. - Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. + tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + false is a boolean and this string field rejects it. tls=true enables an + encrypted connection verified with the pod trust store. Use skip-verify for + a self-signed or invalid server certificate, or preferred to use TLS only + when the server advertises it. preferred also allows a plaintext fallback. + Neither skip-verify nor preferred verifies the server. Alternatively, set + tls=custom and provide a CA via tlsConfig. An omitted value means preferred. enum: + - "false" - "true" - skip-verify - preferred diff --git a/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml index cd936235..ebd8a656 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,19 +104,22 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source type: object tls: description: |- - tls=true enables TLS / SSL encrypted connection to the server. - Use skip-verify if you want to use a self-signed or invalid certificate (server side) - or use preferred to use TLS only when advertised by the server. This is similar - to skip-verify, but additionally allows a fallback to a connection which is - not encrypted. Neither skip-verify nor preferred add any reliable security. - Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. + tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + false is a boolean and this string field rejects it. tls=true enables an + encrypted connection verified with the pod trust store. Use skip-verify for + a self-signed or invalid server certificate, or preferred to use TLS only + when the server advertises it. preferred also allows a plaintext fallback. + Neither skip-verify nor preferred verifies the server. Alternatively, set + tls=custom and provide a CA via tlsConfig. An omitted value means preferred. enum: + - "false" - "true" - skip-verify - preferred diff --git a/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml index 26d83ed6..5190c5d2 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,19 +99,22 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source type: object tls: description: |- - tls=true enables TLS / SSL encrypted connection to the server. - Use skip-verify if you want to use a self-signed or invalid certificate (server side) - or use preferred to use TLS only when advertised by the server. This is similar - to skip-verify, but additionally allows a fallback to a connection which is - not encrypted. Neither skip-verify nor preferred add any reliable security. - Alternatively, set tls=custom and provide a custom TLS configuration via the tlsConfig field. + tls=false disables encryption. Quote it in YAML as tls: "false"; an unquoted + false is a boolean and this string field rejects it. tls=true enables an + encrypted connection verified with the pod trust store. Use skip-verify for + a self-signed or invalid server certificate, or preferred to use TLS only + when the server advertises it. preferred also allows a plaintext fallback. + Neither skip-verify nor preferred verifies the server. Alternatively, set + tls=custom and provide a CA via tlsConfig. An omitted value means preferred. enum: + - "false" - "true" - skip-verify - preferred diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go index 29bcb919..73d84fba 100644 --- a/pkg/clients/awsiam/awsiam.go +++ b/pkg/clients/awsiam/awsiam.go @@ -98,6 +98,10 @@ func InjectToken(ctx context.Context, creds map[string][]byte, region string, return nil } +// InjectIAM is the injector database connectors call. It defaults to Inject. +// Tests replace it so they can assert token injection without calling AWS. +var InjectIAM = Inject + // Inject loads AWS configuration from the environment, resolves the region // (ProviderConfig field > secret "region" key > environment) and injects an RDS // IAM authentication token into creds as the password. It is called on every diff --git a/pkg/clients/mysql/iam.go b/pkg/clients/mysql/iam.go new file mode 100644 index 00000000..512acd30 --- /dev/null +++ b/pkg/clients/mysql/iam.go @@ -0,0 +1,20 @@ +package mysql + +import ( + "context" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" +) + +// ApplyIAM injects a fresh RDS IAM token when source is AWSIAMAuth and reports +// that the MySQL client must allow the cleartext plugin. Any other source is +// left unchanged and cleartext stays off, so a password login keeps its DSN. +func ApplyIAM(ctx context.Context, source string, region *string, creds map[string][]byte) (bool, error) { + if source != awsiam.SourceAWSIAMAuth { + return false, nil + } + if err := awsiam.InjectIAM(ctx, region, creds); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index da4b94c4..d569a56e 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -25,46 +25,44 @@ type mySQLDB struct { tls string } -// New returns a new MySQL database client. -func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { +const defaultTLSMode = "preferred" + +// New returns a new MySQL database client. A nil tls mode becomes preferred. +// An explicit mode, including "false", is left unchanged. cleartext sets +// allowCleartextPasswords, which AWS IAM authentication needs so the driver +// will send the token with the MySQL cleartext client plugin. +func New(creds map[string][]byte, tlsMode *string, binlog *bool, cleartext bool) xsql.DB { endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) password := string(creds[xpv2.CredentialsSecretPasswordKey]) - if tls == nil { - defaultTLS := "preferred" - tls = &defaultTLS + if tlsMode == nil { + mode := defaultTLSMode + tlsMode = &mode } - dsn := DSN(username, password, endpoint, port, *tls, binlog) + dsn := DSN(username, password, endpoint, port, *tlsMode, binlog, cleartext) return mySQLDB{ dsn: dsn, endpoint: endpoint, port: port, - tls: *tls, + tls: *tlsMode, } } -// DSN returns the DSN URL -func DSN(username, password, endpoint, port, tls string, binlog *bool) string { +// DSN returns the DSN URL. cleartext appends allowCleartextPasswords=true. +func DSN(username, password, endpoint, port, tlsMode string, binlog *bool, cleartext 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 + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", username, password, endpoint, port, tlsMode) 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. diff --git a/pkg/clients/mysql/mysql_test.go b/pkg/clients/mysql/mysql_test.go index 13aacdc6..7ce8431d 100644 --- a/pkg/clients/mysql/mysql_test.go +++ b/pkg/clients/mysql/mysql_test.go @@ -3,6 +3,7 @@ package mysql import ( "fmt" "strconv" + "strings" "testing" ) @@ -13,7 +14,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 +32,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 +42,36 @@ func TestDSNURLEscapingWithoutBinLog(t *testing.T) { t.Errorf("DSN string did not match expected output with URL encoded") } } + +func TestNewTLSAndCleartext(t *testing.T) { + creds := map[string][]byte{ + "username": []byte("user"), + "password": []byte("secret"), + "endpoint": []byte("db.example"), + "port": []byte("3306"), + } + + db := New(creds, nil, nil, false).(mySQLDB) + if db.tls != defaultTLSMode { + t.Errorf("nil tls: want %s, got %s", defaultTLSMode, db.tls) + } + if strings.Contains(db.dsn, "allowCleartextPasswords") { + t.Errorf("password login set cleartext: %s", db.dsn) + } + + for _, mode := range []string{"true", "skip-verify", "preferred", "custom", "false"} { + got := New(creds, &mode, nil, false).(mySQLDB) + if got.tls != mode { + t.Errorf("tls: want %s, got %s", mode, got.tls) + } + if strings.Contains(got.dsn, "allowCleartextPasswords") { + t.Errorf("mode %s set cleartext: %s", mode, got.dsn) + } + } + + disabled := "false" + iam := New(creds, &disabled, nil, true).(mySQLDB) + if iam.tls != disabled || !strings.Contains(iam.dsn, "allowCleartextPasswords=true") { + t.Errorf("IAM dsn: got tls=%s dsn=%s", iam.tls, iam.dsn) + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index 5d353b3b..8f43823a 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -47,6 +47,7 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errIAMAuth = "cannot authenticate with AWS IAM" errSelectDB = "cannot select database" errCreateDB = "cannot create database" @@ -94,7 +95,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) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Database] = &connector{} @@ -112,9 +113,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -125,13 +123,17 @@ 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) + tlsName, err := tls.LoadConfig(ctx, c.kube, tls.RegisterKey(providerConfigName), pc.Spec.TLS, pc.Spec.TLSConfig) if err != nil { return nil, errors.Wrap(err, errTLSConfig) } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + cleartext, err := mysql.ApplyIAM(ctx, string(pc.Spec.Credentials.Source), pc.Spec.Credentials.Region, secretData) + if err != nil { + return nil, errors.Wrap(err, errIAMAuth) + } + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext)}, 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..9559a214 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -32,6 +32,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/test" xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -66,10 +67,17 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + orig := awsiam.InjectIAM + awsiam.InjectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + t.Cleanup(func() { awsiam.InjectIAM = orig }) + 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.DB } type args struct { @@ -161,6 +169,97 @@ func TestConnect(t *testing.T) { }, want: errors.Wrap(errBoom, errGetSecret), }, + "SuccessPassword": { + reason: "A password login keeps tls unchanged and does not allow the cleartext plugin.", + 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.CredentialsSourceMySQLConnectionSecret + o.Spec.Credentials.ConnectionSecretRef = &xpv2.SecretReference{Namespace: "ns", Name: "s"} + o.Spec.TLS = ptrStr("true") + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("admin"), + xpv2.CredentialsSecretPasswordKey: []byte("secret"), + } + } + return nil + }), + }, + track: nopUsage, + newDB: func(c map[string][]byte, tlsMode *string, _ *bool, cleartext bool) xsql.DB { + if tlsMode == nil || *tlsMode != "true" { + t.Errorf("tls: want true, got %v", tlsMode) + } + if cleartext { + t.Error("password login enabled cleartext") + } + if got := string(c[xpv2.CredentialsSecretPasswordKey]); got != "secret" { + t.Errorf("password: want secret, got %q", got) + } + return mockDB{} + }, + }, + args: args{ + ctx: context.Background(), + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{Name: "example"}, + }, + }, + }, + }, + }, + "SuccessAWSIAMAuth": { + reason: "AWSIAMAuth injects a token, leaves tls=false unchanged, and allows the cleartext plugin.", + 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"} + o.Spec.TLS = ptrStr("false") + 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(c map[string][]byte, tlsMode *string, _ *bool, cleartext bool) xsql.DB { + if tlsMode == nil || *tlsMode != "false" { + t.Errorf("tls: want false, got %v", tlsMode) + } + if !cleartext { + t.Error("IAM login did not enable cleartext") + } + if got := string(c[xpv2.CredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("password: want iam-token, got %q", got) + } + return mockDB{} + }, + }, + args: args{ + ctx: context.Background(), + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{Name: "example"}, + }, + }, + }, + }, + }, } for name, tc := range cases { diff --git a/pkg/controller/cluster/mysql/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index 9c358aa4..b01b3ebe 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -51,6 +51,7 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errIAMAuth = "cannot authenticate with AWS IAM" errCreateGrant = "cannot create grant" errRevokeGrant = "cannot revoke grant" @@ -102,7 +103,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) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Grant] = &connector{} @@ -120,9 +121,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -133,13 +131,17 @@ 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) + tlsName, err := tls.LoadConfig(ctx, c.kube, tls.RegisterKey(providerConfigName), pc.Spec.TLS, pc.Spec.TLSConfig) if err != nil { return nil, errors.Wrap(err, errTLSConfig) } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + cleartext, err := mysql.ApplyIAM(ctx, string(pc.Spec.Credentials.Source), pc.Spec.Credentials.Region, secretData) + if err != nil { + return nil, errors.Wrap(err, errIAMAuth) + } + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext)}, 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..c9499bd4 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.DB } type args struct { diff --git a/pkg/controller/cluster/mysql/tls/tls.go b/pkg/controller/cluster/mysql/tls/tls.go index 56d46060..d7916a37 100644 --- a/pkg/controller/cluster/mysql/tls/tls.go +++ b/pkg/controller/cluster/mysql/tls/tls.go @@ -15,10 +15,26 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// LoadConfig loads the TLS configuration when tls mode is set to custom and -// returns the tls name of registered configuration. -func LoadConfig(ctx context.Context, kube client.Client, providerConfigName string, mode *string, cfg *v1alpha1.TLSConfig) (*string, error) { - if mode == nil || *mode != "custom" { +const ( + tlsModeCustom = "custom" + + errCARequired = "tlsConfig.caCert name, namespace, and key are required when tls=custom" + errClientPair = "clientCert and clientKey must be set together" + errParseCA = "cannot parse CA bundle from tlsConfig.caCert" +) + +// RegisterKey is the go-sql-driver TLS config name for a cluster-scoped +// ProviderConfig. The driver rejects the reserved names true, false, +// skip-verify, and preferred, so the key is prefixed. +func RegisterKey(providerConfigName string) string { + return "cluster-" + providerConfigName +} + +// LoadConfig registers tls=custom and returns the name to put in the DSN. +// Any other mode, including an explicit "false", is returned unchanged. +// registerKey is the name passed to mysql.RegisterTLSConfig. +func LoadConfig(ctx context.Context, kube client.Client, registerKey string, mode *string, cfg *v1alpha1.TLSConfig) (*string, error) { + if mode == nil || *mode != tlsModeCustom { if cfg != nil { return nil, fmt.Errorf("tlsConfig is allowed only when tls=custom") } @@ -28,28 +44,33 @@ func LoadConfig(ctx context.Context, kube client.Client, providerConfigName stri if err := validateTLSConfig(cfg); err != nil { return nil, err } - - tlsName := fmt.Sprintf("custom-%s", providerConfigName) - err := registerTLS(ctx, kube, tlsName, cfg) - if err != nil { + if err := registerTLS(ctx, kube, registerKey, cfg); err != nil { return nil, err } - return &tlsName, nil + return ®isterKey, nil } 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 || !secretRefComplete(cfg.CACert.SecretRef) { + return errors.New(errCARequired) + } + if secretRefEmpty(cfg.ClientCert.SecretRef) && secretRefEmpty(cfg.ClientKey.SecretRef) { + return nil + } + if !secretRefComplete(cfg.ClientCert.SecretRef) || !secretRefComplete(cfg.ClientKey.SecretRef) { + return errors.New(errClientPair) } return nil } +func secretRefComplete(sel xpv2.SecretKeySelector) bool { + return sel.Name != "" && sel.Namespace != "" && sel.Key != "" +} + +func secretRefEmpty(sel xpv2.SecretKeySelector) bool { + return sel.Name == "" && sel.Namespace == "" && sel.Key == "" +} + func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *v1alpha1.TLSConfig) error { if cfg == nil { return nil @@ -62,19 +83,30 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *v pool := x509.NewCertPool() if ok := pool.AppendCertsFromPEM(caCert); !ok { - return fmt.Errorf("cannot append CA certificate to pool") + return errors.New(errParseCA) } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err + var certs []tls.Certificate + if secretRefComplete(cfg.ClientCert.SecretRef) { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + certs = []tls.Certificate{keyPair} } - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + // ServerName stays empty. go-sql-driver sets it from the endpoint when + // verification is on, which is the hostname check. + return mysql.RegisterTLSConfig(tlsName, clientConfig(pool, certs, cfg.InsecureSkipVerify)) +} + +func clientConfig(pool *x509.CertPool, certs []tls.Certificate, insecure bool) *tls.Config { + return &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 - }) + Certificates: certs, + InsecureSkipVerify: insecure, //nolint:gosec // Set only when tlsConfig.insecureSkipVerify is true. + } } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *v1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/cluster/mysql/tls/tls_test.go b/pkg/controller/cluster/mysql/tls/tls_test.go new file mode 100644 index 00000000..6ccb7c08 --- /dev/null +++ b/pkg/controller/cluster/mysql/tls/tls_test.go @@ -0,0 +1,198 @@ +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" +) + +const testConfigName = "aurora" + +func TestLoadConfigPassthrough(t *testing.T) { + for _, mode := range []string{"true", "skip-verify", "preferred", "false"} { + got, err := LoadConfig(context.Background(), nil, "unused", &mode, nil) + if err != nil { + t.Fatalf("mode %s: %v", mode, err) + } + if got == nil || *got != mode { + t.Errorf("mode %s: got %v", mode, got) + } + } + got, err := LoadConfig(context.Background(), nil, "unused", nil, nil) + if err != nil || got != nil { + t.Errorf("nil mode: got %v err %v", got, err) + } +} + +func TestLoadConfigCAOnly(t *testing.T) { + mode := tlsModeCustom + key := RegisterKey(testConfigName) + got, err := LoadConfig(context.Background(), secretClient(t, certPEM(t)), key, &mode, caOnly()) + if err != nil { + t.Fatal(err) + } + if got == nil || *got != key { + t.Fatalf("register key: got %v", got) + } +} + +func TestLoadConfigClientPair(t *testing.T) { + der, key := certificate(t) + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + rawKey, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + clientKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: rawKey}) + ca := certPEM(t) + mode := tlsModeCustom + cfg := caOnly() + cfg.ClientCert.SecretRef = xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "tls", Namespace: "ns"}, + Key: "client.pem", + } + cfg.ClientKey.SecretRef = xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "tls", Namespace: "ns"}, + Key: "client-key.pem", + } + kube := &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + secret, ok := obj.(*corev1.Secret) + if !ok { + t.Fatalf("unexpected %T", obj) + } + secret.Data = map[string][]byte{ + "global-bundle.pem": ca, + "client.pem": cert, + "client-key.pem": clientKey, + } + return nil + }), + } + got, err := LoadConfig(context.Background(), kube, RegisterKey(testConfigName), &mode, cfg) + if err != nil { + t.Fatal(err) + } + if got == nil || *got != RegisterKey(testConfigName) { + t.Fatalf("register key: got %v", got) + } +} + +func TestClientConfigVerifying(t *testing.T) { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certPEM(t)) { + t.Fatal("pem") + } + cfg := clientConfig(pool, nil, false) + if cfg.InsecureSkipVerify || cfg.ServerName != "" || cfg.RootCAs == nil || len(cfg.Certificates) != 0 { + t.Fatalf("verifying config: insecure=%v server=%q certs=%d", cfg.InsecureSkipVerify, cfg.ServerName, len(cfg.Certificates)) + } + if cfg.MinVersion != tls.VersionTLS12 { + t.Fatalf("min version %d", cfg.MinVersion) + } + + der, key := certificate(t) + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + pair, err := tls.X509KeyPair(cert, keyBytes) + if err != nil { + t.Fatal(err) + } + withClient := clientConfig(pool, []tls.Certificate{pair}, false) + if len(withClient.Certificates) != 1 || withClient.ServerName != "" { + t.Fatalf("client cert config: certs=%d server=%q", len(withClient.Certificates), withClient.ServerName) + } +} + +func TestLoadConfigClientCertWithoutKey(t *testing.T) { + mode := tlsModeCustom + cfg := caOnly() + cfg.ClientCert.SecretRef = xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "tls", Namespace: "ns"}, + Key: "client.pem", + } + _, err := LoadConfig(context.Background(), nil, RegisterKey(testConfigName), &mode, cfg) + if err == nil || !strings.Contains(err.Error(), "must be set together") { + t.Fatalf("got %v", err) + } +} + +func TestLoadConfigNonPEM(t *testing.T) { + mode := tlsModeCustom + _, err := LoadConfig(context.Background(), secretClient(t, []byte("not a certificate")), RegisterKey(testConfigName), &mode, caOnly()) + if err == nil || !strings.Contains(err.Error(), "cannot parse CA bundle") { + t.Fatalf("got %v", err) + } +} + +func caOnly() *v1alpha1.TLSConfig { + return &v1alpha1.TLSConfig{ + CACert: v1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "amazon-rds-ca", Namespace: "crossplane-system"}, + Key: "global-bundle.pem", + }}, + } +} + +func secretClient(t *testing.T, pemBytes []byte) client.Client { + t.Helper() + return &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + secret, ok := obj.(*corev1.Secret) + if !ok { + t.Fatalf("unexpected %T", obj) + } + secret.Data = map[string][]byte{"global-bundle.pem": pemBytes} + return nil + }), + } +} + +func certPEM(t *testing.T) []byte { + t.Helper() + der, _ := certificate(t) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func certificate(t *testing.T) ([]byte, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "rds.example.com"}, + 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, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return der, key +} diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 93b46d2f..0c802699 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -50,6 +50,7 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errIAMAuth = "cannot authenticate with AWS IAM" errSelectUser = "cannot select user" errCreateUser = "cannot create user" @@ -97,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.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.DB } var _ managed.TypedExternalConnector[*v1alpha1.User] = &connector{} @@ -115,9 +116,6 @@ 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. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -128,14 +126,18 @@ 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) + tlsName, err := tls.LoadConfig(ctx, c.kube, tls.RegisterKey(providerConfigName), pc.Spec.TLS, pc.Spec.TLSConfig) if err != nil { return nil, errors.Wrap(err, errTLSConfig) } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + cleartext, err := mysql.ApplyIAM(ctx, string(pc.Spec.Credentials.Source), pc.Spec.Credentials.Region, secretData) + if err != nil { + return nil, errors.Wrap(err, errIAMAuth) + } return &external{ - db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext), 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..e7a6ee69 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.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/database/reconciler.go b/pkg/controller/namespaced/mysql/database/reconciler.go index 5d356023..63df45bc 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler.go +++ b/pkg/controller/namespaced/mysql/database/reconciler.go @@ -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) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Database] = &connector{} @@ -107,12 +107,12 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, err } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.ProviderConfigName, providerInfo.TLS, providerInfo.TLSConfig) + tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.TLSRegisterKey, providerInfo.TLS, providerInfo.TLSConfig) if err != nil { 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)}, 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..3f3ce4ff 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.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/grant/reconciler.go b/pkg/controller/namespaced/mysql/grant/reconciler.go index 38f892c2..971dc0ba 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler.go @@ -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) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Grant] = &connector{} @@ -116,12 +116,12 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Grant) ( return nil, err } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.ProviderConfigName, providerInfo.TLS, providerInfo.TLSConfig) + tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.TLSRegisterKey, providerInfo.TLS, providerInfo.TLSConfig) if err != nil { 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)}, 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..85030dbf 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.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 908dfc80..9d112daf 100644 --- a/pkg/controller/namespaced/mysql/provider/provider.go +++ b/pkg/controller/namespaced/mysql/provider/provider.go @@ -1,93 +1,132 @@ package provider import ( + "context" + 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/resource" - "context" - "github.com/crossplane-contrib/provider-sql/apis/namespaced/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/namespaced/errors" - - "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql/tls" ) type ProviderInfo struct { ProviderConfigName string + TLSRegisterKey string SecretData map[string][]byte TLS *string TLSConfig *v1alpha1.TLSConfig + // Cleartext is true when the credentials source is AWS IAM auth. The + // MySQL client then sets allowCleartextPasswords so the driver will send + // the token. Password logins leave it false. + Cleartext bool +} + +type resolvedConfig struct { + secretKey client.ObjectKey + tlsMode *string + tlsConfig *v1alpha1.TLSConfig + keyMapping map[string]string + source string + region *string + registerKey string } 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 - ) + resolved, err := resolveProviderConfig(ctx, kube, mg) + if err != nil { + return ProviderInfo{}, err + } + data, cleartext, err := loadCredentials(ctx, kube, resolved) + if err != nil { + return ProviderInfo{}, err + } + return ProviderInfo{ + ProviderConfigName: mg.GetProviderConfigReference().Name, + TLSRegisterKey: resolved.registerKey, + SecretData: data, + TLS: resolved.tlsMode, + TLSConfig: resolved.tlsConfig, + Cleartext: cleartext, + }, nil +} +func resolveProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { switch mg.GetProviderConfigReference().Kind { case v1alpha1.ProviderConfigKind: - providerConfig := &v1alpha1.ProviderConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: mg.GetProviderConfigReference().Name, - Namespace: mg.GetNamespace(), - }, - } - - if err := kube.Get(ctx, client.ObjectKeyFromObject(providerConfig), providerConfig); err != nil { - return ProviderInfo{}, errors.GetProviderConfigError(err) - } + return resolveNamespaced(ctx, kube, mg) + case v1alpha1.ClusterProviderConfigKind: + return resolveCluster(ctx, kube, mg) + default: + return resolvedConfig{}, errors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) + } +} - secretKey = &client.ObjectKey{ +func resolveNamespaced(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { + providerConfig := &v1alpha1.ProviderConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.GetProviderConfigReference().Name, + Namespace: mg.GetNamespace(), + }, + } + if err := kube.Get(ctx, client.ObjectKeyFromObject(providerConfig), providerConfig); err != nil { + return resolvedConfig{}, errors.GetProviderConfigError(err) + } + return resolvedConfig{ + secretKey: client.ObjectKey{ Name: providerConfig.Spec.Credentials.ConnectionSecretRef.Name, Namespace: mg.GetNamespace(), - } - tlsMode = providerConfig.Spec.TLS - tlsConfig = providerConfig.Spec.TLSConfig - keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() - - case v1alpha1.ClusterProviderConfigKind: - clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: mg.GetProviderConfigReference().Name, - }, - } - - if err := kube.Get(ctx, client.ObjectKeyFromObject(clusterProviderConfig), clusterProviderConfig); err != nil { - return ProviderInfo{}, errors.GetClusterProviderConfigError(err) - } + }, + tlsMode: providerConfig.Spec.TLS, + tlsConfig: providerConfig.Spec.TLSConfig, + keyMapping: providerConfig.Spec.Credentials.SecretKeyMapping.ToMap(), + source: string(providerConfig.Spec.Credentials.Source), + region: providerConfig.Spec.Credentials.Region, + registerKey: tls.ProviderConfigKey(mg.GetNamespace(), providerConfig.GetName()), + }, nil +} - secretKey = &client.ObjectKey{ +func resolveCluster(ctx context.Context, kube client.Client, mg resource.ModernManaged) (resolvedConfig, error) { + clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.GetProviderConfigReference().Name, + }, + } + if err := kube.Get(ctx, client.ObjectKeyFromObject(clusterProviderConfig), clusterProviderConfig); err != nil { + return resolvedConfig{}, errors.GetClusterProviderConfigError(err) + } + return resolvedConfig{ + secretKey: client.ObjectKey{ Name: clusterProviderConfig.Spec.Credentials.ConnectionSecretRef.Name, Namespace: clusterProviderConfig.Spec.Credentials.ConnectionSecretRef.Namespace, - } - tlsMode = clusterProviderConfig.Spec.TLS - tlsConfig = clusterProviderConfig.Spec.TLSConfig - keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() - - default: - return ProviderInfo{}, errors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) - } + }, + tlsMode: clusterProviderConfig.Spec.TLS, + tlsConfig: clusterProviderConfig.Spec.TLSConfig, + keyMapping: clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap(), + source: string(clusterProviderConfig.Spec.Credentials.Source), + region: clusterProviderConfig.Spec.Credentials.Region, + registerKey: tls.ClusterProviderConfigKey(clusterProviderConfig.GetName()), + }, nil +} - if secretKey.Name == "" || secretKey.Namespace == "" { - return ProviderInfo{}, errors.MissingSecretRefError() +func loadCredentials(ctx context.Context, kube client.Client, resolved resolvedConfig) (map[string][]byte, bool, error) { + if resolved.secretKey.Name == "" || resolved.secretKey.Namespace == "" { + return nil, false, errors.MissingSecretRefError() } - s := &corev1.Secret{} - err := kube.Get(ctx, *secretKey, s) + if err := kube.Get(ctx, resolved.secretKey, s); err != nil { + return nil, false, errors.GetSecretError(err) + } + data := xsql.RemapCredentialKeys(s.Data, resolved.keyMapping) + cleartext, err := mysql.ApplyIAM(ctx, resolved.source, resolved.region, data) if err != nil { - return ProviderInfo{}, errors.GetSecretError(err) + return nil, false, err } - - return ProviderInfo{ - ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), - TLS: tlsMode, - TLSConfig: tlsConfig, - }, nil + return data, 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..8128a3fe --- /dev/null +++ b/pkg/controller/namespaced/mysql/provider/provider_test.go @@ -0,0 +1,137 @@ +package provider + +import ( + "context" + "testing" + + 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" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql/tls" +) + +func TestGetProviderConfigAWSIAMAuth(t *testing.T) { + orig := awsiam.InjectIAM + awsiam.InjectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + t.Cleanup(func() { awsiam.InjectIAM = orig }) + + conn := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + } + for _, mode := range []string{"true", "skip-verify", "preferred", "custom", "false"} { + t.Run("ProviderConfig/"+mode, func(t *testing.T) { + info := getInfo(t, &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.LocalSecretReference{Name: "s"} + o.Spec.TLS = &mode + case *corev1.Secret: + o.Data = conn + } + return nil + }), + }, managed("default", v1alpha1.ProviderConfigKind)) + assertIAM(t, info, mode, tls.ProviderConfigKey("default", "aurora")) + }) + + t.Run("ClusterProviderConfig/"+mode, func(t *testing.T) { + info := getInfo(t, &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ClusterProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Namespace: "crossplane-system", Name: "s"} + o.Spec.TLS = &mode + case *corev1.Secret: + o.Data = conn + } + return nil + }), + }, managed("default", v1alpha1.ClusterProviderConfigKind)) + assertIAM(t, info, mode, tls.ClusterProviderConfigKey("aurora")) + }) + } +} + +func TestGetProviderConfigPassword(t *testing.T) { + disabled := "false" + info := getInfo(t, &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + o.Spec.TLS = &disabled + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("admin"), + xpv2.CredentialsSecretPasswordKey: []byte("secret"), + } + } + return nil + }), + }, managed("default", v1alpha1.ProviderConfigKind)) + if info.Cleartext { + t.Error("password login enabled cleartext") + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "secret" { + t.Errorf("password: got %q", got) + } + if info.TLS == nil || *info.TLS != disabled { + t.Errorf("tls: got %v", info.TLS) + } +} + +func assertIAM(t *testing.T, info ProviderInfo, mode, registerKey string) { + t.Helper() + if !info.Cleartext { + t.Error("IAM login did not enable cleartext") + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("password: got %q", got) + } + if info.TLS == nil || *info.TLS != mode { + t.Errorf("tls: want %s, got %v", mode, info.TLS) + } + if info.TLSRegisterKey != registerKey { + t.Errorf("register key: want %s, got %s", registerKey, info.TLSRegisterKey) + } +} + +func managed(namespace, kind string) *v1alpha1.Database { + return &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: v1alpha1.DatabaseSpec{ + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{ + Kind: kind, + Name: "aurora", + }, + }, + }, + } +} + +func getInfo(t *testing.T, kube client.Client, mg *v1alpha1.Database) ProviderInfo { + t.Helper() + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatalf("GetProviderConfig: %v", err) + } + return info +} diff --git a/pkg/controller/namespaced/mysql/tls/tls.go b/pkg/controller/namespaced/mysql/tls/tls.go index 2daa7120..0430d3cd 100644 --- a/pkg/controller/namespaced/mysql/tls/tls.go +++ b/pkg/controller/namespaced/mysql/tls/tls.go @@ -15,48 +15,75 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// LoadConfig loads the TLS configuration when tls mode is set to custom and -// returns the tls name of registered configuration. +const ( + tlsModeCustom = "custom" + + errCARequired = "tlsConfig.caCert name, namespace, and key are required when tls=custom" + errClientPair = "clientCert and clientKey must be set together" + errParseCA = "cannot parse CA bundle from tlsConfig.caCert" +) + +// ProviderConfigKey is the go-sql-driver TLS config name for a namespaced +// ProviderConfig. The namespace is part of the key so two namespaces do not +// share one trust pool. The driver rejects the reserved names true, false, +// skip-verify, and preferred. +func ProviderConfigKey(namespace, name string) string { + return "ns-" + namespace + "-" + name +} + +// ClusterProviderConfigKey is the go-sql-driver TLS config name for a +// ClusterProviderConfig. +func ClusterProviderConfigKey(name string) string { + return "cpc-" + name +} + +// LoadConfig registers tls=custom and returns the name to put in the DSN. +// Any other mode, including an explicit "false", is returned unchanged. +// registerKey is the name passed to mysql.RegisterTLSConfig. func LoadConfig( ctx context.Context, kube client.Client, - providerConfigName string, + registerKey string, tlsMode *string, tlsConfig *namespacedv1alpha1.TLSConfig, ) (*string, error) { - if tlsMode == nil || *tlsMode != "custom" { + if tlsMode == nil || *tlsMode != tlsModeCustom { if tlsConfig != nil { return nil, fmt.Errorf("tlsConfig is allowed only when tls=custom") } - return tlsMode, nil } if err := validateTLSConfig(tlsConfig); err != nil { return nil, err } - - tlsName := fmt.Sprintf("custom-%s", providerConfigName) - err := registerTLS(ctx, kube, tlsName, tlsConfig) - if err != nil { + if err := registerTLS(ctx, kube, registerKey, tlsConfig); err != nil { return nil, err } - return &tlsName, nil + return ®isterKey, nil } 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 || !secretRefComplete(cfg.CACert.SecretRef) { + return errors.New(errCARequired) + } + if secretRefEmpty(cfg.ClientCert.SecretRef) && secretRefEmpty(cfg.ClientKey.SecretRef) { + return nil + } + if !secretRefComplete(cfg.ClientCert.SecretRef) || !secretRefComplete(cfg.ClientKey.SecretRef) { + return errors.New(errClientPair) } return nil } +func secretRefComplete(sel xpv2.SecretKeySelector) bool { + return sel.Name != "" && sel.Namespace != "" && sel.Key != "" +} + +func secretRefEmpty(sel xpv2.SecretKeySelector) bool { + return sel.Name == "" && sel.Namespace == "" && sel.Key == "" +} + func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *namespacedv1alpha1.TLSConfig) error { if cfg == nil { return nil @@ -69,19 +96,30 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *n pool := x509.NewCertPool() if ok := pool.AppendCertsFromPEM(caCert); !ok { - return fmt.Errorf("cannot append CA certificate to pool") + return errors.New(errParseCA) } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err + var certs []tls.Certificate + if secretRefComplete(cfg.ClientCert.SecretRef) { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + certs = []tls.Certificate{keyPair} } - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + // ServerName stays empty. go-sql-driver sets it from the endpoint when + // verification is on, which is the hostname check. + return mysql.RegisterTLSConfig(tlsName, clientConfig(pool, certs, cfg.InsecureSkipVerify)) +} + +func clientConfig(pool *x509.CertPool, certs []tls.Certificate, insecure bool) *tls.Config { + return &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 - }) + Certificates: certs, + InsecureSkipVerify: insecure, //nolint:gosec // Set only when tlsConfig.insecureSkipVerify is true. + } } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *namespacedv1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/namespaced/mysql/tls/tls_test.go b/pkg/controller/namespaced/mysql/tls/tls_test.go new file mode 100644 index 00000000..e3e00c50 --- /dev/null +++ b/pkg/controller/namespaced/mysql/tls/tls_test.go @@ -0,0 +1,140 @@ +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + namespacedv1alpha1 "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" +) + +const ( + testNamespace = "default" + testConfigName = "aurora" +) + +func TestRegisterKeys(t *testing.T) { + if got := ProviderConfigKey(testNamespace, testConfigName); got != "ns-default-aurora" { + t.Errorf("namespaced key: %s", got) + } + if got := ClusterProviderConfigKey(testConfigName); got != "cpc-aurora" { + t.Errorf("cluster key: %s", got) + } +} + +func TestLoadConfigPassthrough(t *testing.T) { + for _, mode := range []string{"true", "skip-verify", "preferred", "false"} { + got, err := LoadConfig(context.Background(), nil, "unused", &mode, nil) + if err != nil { + t.Fatalf("mode %s: %v", mode, err) + } + if got == nil || *got != mode { + t.Errorf("mode %s: got %v", mode, got) + } + } +} + +func TestLoadConfigCAOnly(t *testing.T) { + mode := tlsModeCustom + key := ProviderConfigKey(testNamespace, testConfigName) + got, err := LoadConfig(context.Background(), secretClient(t, certPEM(t)), key, &mode, caOnly()) + if err != nil { + t.Fatal(err) + } + if got == nil || *got != key { + t.Fatalf("register key: got %v", got) + } + cfg := clientConfig(poolFrom(t, certPEM(t)), nil, false) + if cfg.InsecureSkipVerify || cfg.ServerName != "" || cfg.RootCAs == nil || len(cfg.Certificates) != 0 { + t.Fatalf("verifying config: insecure=%v server=%q certs=%d", cfg.InsecureSkipVerify, cfg.ServerName, len(cfg.Certificates)) + } +} + +func TestLoadConfigClientCertWithoutKey(t *testing.T) { + mode := tlsModeCustom + cfg := caOnly() + cfg.ClientCert.SecretRef = xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "tls", Namespace: "ns"}, + Key: "client.pem", + } + _, err := LoadConfig(context.Background(), nil, ProviderConfigKey(testNamespace, testConfigName), &mode, cfg) + if err == nil || !strings.Contains(err.Error(), "must be set together") { + t.Fatalf("got %v", err) + } +} + +func TestLoadConfigNonPEM(t *testing.T) { + mode := tlsModeCustom + _, err := LoadConfig(context.Background(), secretClient(t, []byte("not a certificate")), ProviderConfigKey(testNamespace, testConfigName), &mode, caOnly()) + if err == nil || !strings.Contains(err.Error(), "cannot parse CA bundle") { + t.Fatalf("got %v", err) + } +} + +func poolFrom(t *testing.T, pemBytes []byte) *x509.CertPool { + t.Helper() + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + t.Fatal("pem") + } + return pool +} + +func caOnly() *namespacedv1alpha1.TLSConfig { + return &namespacedv1alpha1.TLSConfig{ + CACert: namespacedv1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "amazon-rds-ca", Namespace: "crossplane-system"}, + Key: "global-bundle.pem", + }}, + } +} + +func secretClient(t *testing.T, pemBytes []byte) client.Client { + t.Helper() + return &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + secret, ok := obj.(*corev1.Secret) + if !ok { + t.Fatalf("unexpected %T", obj) + } + secret.Data = map[string][]byte{"global-bundle.pem": pemBytes} + return nil + }), + } +} + +func certPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "rds.example.com"}, + 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, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 0ebdc073..6a728d34 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -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) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.User] = &connector{} @@ -111,13 +111,13 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.User) (m return nil, err } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.ProviderConfigName, providerInfo.TLS, providerInfo.TLSConfig) + tlsName, err := tls.LoadConfig(ctx, c.kube, providerInfo.TLSRegisterKey, providerInfo.TLS, providerInfo.TLSConfig) if err != nil { return nil, errors.Wrap(err, errTLSConfig) } return &external{ - db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext), 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..f981bf00 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.DB } type args struct {