From 3ab042ce1ab20b0f1fa5bcdb05d0627bb2a912d6 Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 02:53:33 +0200 Subject: [PATCH 01/11] feat: add AWS IAM authentication client package Add pkg/clients/awsiam: generates short-lived RDS IAM auth tokens and injects them as the database password, with region resolution and a test seam (injectable TokenBuilder) so the logic is unit-tested without AWS credentials or network. Part of #347. Signed-off-by: michielvha --- go.mod | 3 + go.sum | 6 + pkg/clients/awsiam/awsiam.go | 93 +++++++++++++++ pkg/clients/awsiam/awsiam_test.go | 182 ++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 pkg/clients/awsiam/awsiam.go create mode 100644 pkg/clients/awsiam/awsiam_test.go diff --git a/go.mod b/go.mod index 170d3fca..81677f8a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ 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/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 +34,7 @@ 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/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..9584b2bc 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,12 @@ 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/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/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/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go new file mode 100644 index 00000000..727f9d26 --- /dev/null +++ b/pkg/clients/awsiam/awsiam.go @@ -0,0 +1,93 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package awsiam generates short-lived AWS RDS IAM authentication tokens and +// injects them in place of a static database password. It lets provider-sql +// connect to Aurora/RDS using an IAM identity (EKS Pod Identity or IRSA) +// instead of a password stored in a Kubernetes Secret. +package awsiam + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "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" +) + +// 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. +func InjectToken(ctx context.Context, creds map[string][]byte, region string, + awsCreds aws.CredentialsProvider, build TokenBuilder) error { + endpoint := string(creds[xpv1.ResourceCredentialsSecretEndpointKey]) + port := string(creds[xpv1.ResourceCredentialsSecretPortKey]) + username := string(creds[xpv1.ResourceCredentialsSecretUserKey]) + + 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[xpv1.ResourceCredentialsSecretPasswordKey] = []byte(token) + return nil +} diff --git a/pkg/clients/awsiam/awsiam_test.go b/pkg/clients/awsiam/awsiam_test.go new file mode 100644 index 00000000..23285ff9 --- /dev/null +++ b/pkg/clients/awsiam/awsiam_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsiam + +import ( + "context" + "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" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" +) + +// builderCall records what a stub TokenBuilder was invoked with, so a test can +// assert on it afterwards. +type builderCall struct { + endpoint string + region string + dbUser string + called bool +} + +// recordingBuilder returns a TokenBuilder that records its inputs and returns +// the supplied token/err. This is the "fake AWS": it never calls AWS, so the +// tests are deterministic and need no credentials or network. +func recordingBuilder(token string, err error, rec *builderCall) TokenBuilder { + return func(_ context.Context, endpoint, region, dbUser string, _ aws.CredentialsProvider, _ ...func(*auth.BuildAuthTokenOptions)) (string, error) { + rec.endpoint, rec.region, rec.dbUser, rec.called = endpoint, region, dbUser, true + return token, err + } +} + +func TestResolveRegion(t *testing.T) { + ptr := func(s string) *string { return &s } + + cases := map[string]struct { + specRegion *string + creds map[string][]byte + cfgRegion string + want string + }{ + "SpecFieldWins": { + specRegion: ptr("eu-west-1"), + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "eu-west-1", + }, + "SecretWhenSpecNil": { + specRegion: nil, + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "us-east-1", + }, + "SecretWhenSpecEmpty": { + specRegion: ptr(""), + creds: map[string][]byte{regionKey: []byte("us-east-1")}, + cfgRegion: "ap-south-1", + want: "us-east-1", + }, + "ConfigWhenNothingElse": { + specRegion: nil, + creds: nil, + cfgRegion: "ap-south-1", + want: "ap-south-1", + }, + "EmptyWhenAllEmpty": { + specRegion: nil, + creds: nil, + cfgRegion: "", + want: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := ResolveRegion(tc.specRegion, tc.creds, tc.cfgRegion); got != tc.want { + t.Errorf("ResolveRegion(...): want %q, got %q", tc.want, got) + } + }) + } +} + +func 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{ + xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), + xpv1.ResourceCredentialsSecretUserKey: []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) + } + + // The token must be written into the password slot. + if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "FAKE_TOKEN" { + t.Errorf("password: want %q, got %q", "FAKE_TOKEN", got) + } + // The builder must have received endpoint as host:port, plus region and user. + 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("MissingUsername", func(t *testing.T) { + creds := fullCreds() + delete(creds, xpv1.ResourceCredentialsSecretUserKey) + 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 { + 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 { + 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) + } + // The password must remain unset when token generation fails. + if _, ok := creds[xpv1.ResourceCredentialsSecretPasswordKey]; ok { + t.Error("password must not be set when token generation fails") + } + }) +} From b558feabf52dd35b2c77693189fe24868500cbb7 Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 03:28:19 +0200 Subject: [PATCH 02/11] feat: add AWSIAMAuth credential source to API types Add the AWSIAMAuth credential source value and an optional Region field to the MySQL and PostgreSQL ProviderConfig (and namespaced ClusterProviderConfig) credential types, for both cluster and namespaced API groups. Regenerate deepcopy and CRDs. Part of #347. Signed-off-by: michielvha --- apis/cluster/mysql/v1alpha1/provider_types.go | 13 ++++++++++++- .../cluster/mysql/v1alpha1/zz_generated.deepcopy.go | 5 +++++ apis/cluster/postgresql/v1alpha1/provider_types.go | 13 ++++++++++++- .../postgresql/v1alpha1/zz_generated.deepcopy.go | 5 +++++ .../mysql/v1alpha1/cluster_provider_types.go | 8 +++++++- apis/namespaced/mysql/v1alpha1/provider_types.go | 13 ++++++++++++- .../mysql/v1alpha1/zz_generated.deepcopy.go | 10 ++++++++++ .../postgresql/v1alpha1/cluster_provider_types.go | 8 +++++++- .../postgresql/v1alpha1/provider_types.go | 13 ++++++++++++- .../postgresql/v1alpha1/zz_generated.deepcopy.go | 10 ++++++++++ .../mysql.sql.crossplane.io_providerconfigs.yaml | 7 +++++++ ....sql.m.crossplane.io_clusterproviderconfigs.yaml | 7 +++++++ .../mysql.sql.m.crossplane.io_providerconfigs.yaml | 7 +++++++ ...ostgresql.sql.crossplane.io_providerconfigs.yaml | 7 +++++++ ....sql.m.crossplane.io_clusterproviderconfigs.yaml | 7 +++++++ ...tgresql.sql.m.crossplane.io_providerconfigs.yaml | 7 +++++++ 16 files changed, 134 insertions(+), 6 deletions(-) diff --git a/apis/cluster/mysql/v1alpha1/provider_types.go b/apis/cluster/mysql/v1alpha1/provider_types.go index a6dc93fa..104fcc41 100644 --- a/apis/cluster/mysql/v1alpha1/provider_types.go +++ b/apis/cluster/mysql/v1alpha1/provider_types.go @@ -59,12 +59,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 +82,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go index d7b2096f..a4dc4747 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -519,6 +519,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. diff --git a/apis/cluster/postgresql/v1alpha1/provider_types.go b/apis/cluster/postgresql/v1alpha1/provider_types.go index e6c993d9..b53aec48 100644 --- a/apis/cluster/postgresql/v1alpha1/provider_types.go +++ b/apis/cluster/postgresql/v1alpha1/provider_types.go @@ -43,12 +43,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 +66,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..594a0a10 100644 --- a/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -879,6 +879,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..1b767c3c 100644 --- a/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/cluster_provider_types.go @@ -45,7 +45,7 @@ type ClusterProviderConfigSpec struct { // ClusterProviderCredentials required to authenticate. type ClusterProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source MySQLConnectionSecretSource `json:"source"` // A CredentialsSecretRef is a reference to a MySQL connection secret @@ -58,6 +58,12 @@ type ClusterProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // A ClusterProviderConfigStatus reflects the observed state of a ClusterProviderConfig. diff --git a/apis/namespaced/mysql/v1alpha1/provider_types.go b/apis/namespaced/mysql/v1alpha1/provider_types.go index 25abc869..9283870a 100644 --- a/apis/namespaced/mysql/v1alpha1/provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/provider_types.go @@ -61,12 +61,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 +84,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index 1eb25e8c..78dbdfe0 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -143,6 +143,11 @@ func (in *ClusterProviderCredentials) DeepCopyInto(out *ClusterProviderCredentia *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderCredentials. @@ -637,6 +642,11 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials. diff --git a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go index 602b97ba..1a5bff97 100644 --- a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go @@ -41,7 +41,7 @@ type ClusterProviderConfigSpec struct { // 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 +54,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..a5f030f2 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -45,12 +45,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 +68,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..336f51d5 100644 --- a/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -118,6 +118,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. @@ -992,6 +997,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/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml index 5b7c0366..9de8b4f3 100644 --- a/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml +++ b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml @@ -70,6 +70,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -97,6 +103,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml index cd936235..5d69232c 100644 --- a/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_clusterproviderconfigs.yaml @@ -71,6 +71,12 @@ spec: - name - namespace type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -98,6 +104,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml index 26d83ed6..9ad619ef 100644 --- a/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_providerconfigs.yaml @@ -66,6 +66,12 @@ spec: required: - name type: object + region: + description: |- + Region is the AWS region used to generate the IAM authentication token + when source is AWSIAMAuth. When unset it falls back to a "region" key in + the connection secret, then to the controller's AWS environment. + type: string secretKeyMapping: description: |- SecretKeyMapping allows overriding the default secret key names used @@ -93,6 +99,7 @@ spec: description: Source of the provider credentials. enum: - MySQLConnectionSecret + - AWSIAMAuth type: string required: - source diff --git a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml index b6e9c167..255e7d91 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 diff --git a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml index 35a5950a..4db7e73c 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 diff --git a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml index 0be259a2..286b1460 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 From daa0aa631078bc2b9c208578a7c050fd21eb3a26 Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 03:33:17 +0200 Subject: [PATCH 03/11] feat(mysql): plumb allowCleartextPasswords flag through the client Add a cleartext parameter to mysql.New/DSN that appends allowCleartextPasswords=true to the DSN when set. AWS RDS IAM authentication needs this because the AWSAuthenticationPlugin uses MySQL's cleartext client plugin, which go-sql-driver refuses by default. This commit only plumbs the flag (all existing callers pass false, so behaviour is unchanged); the IAM reconciler changes that set it follow. Part of #347. Signed-off-by: michielvha --- pkg/clients/mysql/mysql.go | 32 ++++++++----------- pkg/clients/mysql/mysql_test.go | 17 ++++++++-- .../cluster/mysql/database/reconciler.go | 4 +-- .../cluster/mysql/database/reconciler_test.go | 2 +- .../cluster/mysql/grant/reconciler.go | 4 +-- .../cluster/mysql/grant/reconciler_test.go | 2 +- .../cluster/mysql/user/reconciler.go | 4 +-- .../cluster/mysql/user/reconciler_test.go | 2 +- .../namespaced/mysql/database/reconciler.go | 4 +-- .../mysql/database/reconciler_test.go | 2 +- .../namespaced/mysql/grant/reconciler.go | 4 +-- .../namespaced/mysql/grant/reconciler_test.go | 2 +- .../namespaced/mysql/user/reconciler.go | 4 +-- .../namespaced/mysql/user/reconciler_test.go | 2 +- 14 files changed, 47 insertions(+), 38 deletions(-) diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index da4b94c4..18bb0375 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -25,8 +25,10 @@ type mySQLDB struct { tls string } -// New returns a new MySQL database client. -func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { +// New returns a new MySQL database client. When cleartext is true the DSN sets +// allowCleartextPasswords=true, which is required for AWS RDS IAM authentication +// (the AWSAuthenticationPlugin uses MySQL's cleartext client plugin). +func New(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB { endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) @@ -35,7 +37,7 @@ func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { defaultTLS := "preferred" tls = &defaultTLS } - dsn := DSN(username, password, endpoint, port, *tls, binlog) + dsn := DSN(username, password, endpoint, port, *tls, binlog, cleartext) return mySQLDB{ dsn: dsn, @@ -45,26 +47,20 @@ func New(creds map[string][]byte, tls *string, binlog *bool) xsql.DB { } } -// DSN returns the DSN URL -func DSN(username, password, endpoint, port, tls string, binlog *bool) string { +// DSN returns the DSN URL. When cleartext is true, allowCleartextPasswords=true +// is appended (required for AWS RDS IAM authentication). +func DSN(username, password, endpoint, port, tls string, binlog *bool, cleartext bool) string { // 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, tls) if binlog != nil { - return fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s&sql_log_bin=%s", - username, - password, - endpoint, - port, - tls, - strconv.FormatBool(*binlog)) + dsn += "&sql_log_bin=" + strconv.FormatBool(*binlog) } - return fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", - username, - password, - endpoint, - port, - tls) + if cleartext { + dsn += "&allowCleartextPasswords=true" + } + 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..18548eab 100644 --- a/pkg/clients/mysql/mysql_test.go +++ b/pkg/clients/mysql/mysql_test.go @@ -13,7 +13,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 +31,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 +41,16 @@ func TestDSNURLEscapingWithoutBinLog(t *testing.T) { t.Errorf("DSN string did not match expected output with URL encoded") } } + +func TestDSNCleartext(t *testing.T) { + base := "username:password@tcp(endpoint:3306)/?tls=true" + + // cleartext=true appends allowCleartextPasswords=true (required for IAM auth). + if got := DSN("username", "password", "endpoint", "3306", "true", nil, true); got != base+"&allowCleartextPasswords=true" { + t.Errorf("cleartext DSN: want %q, got %q", base+"&allowCleartextPasswords=true", got) + } + // cleartext=false must NOT append it (guards existing non-IAM users). + if got := DSN("username", "password", "endpoint", "3306", "true", nil, false); got != base { + t.Errorf("non-cleartext DSN: want %q, got %q", base, got) + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index 5d353b3b..2392a1d3 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/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.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{} @@ -131,7 +131,7 @@ 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, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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..3192582e 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -69,7 +69,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/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index 9c358aa4..920ad6c9 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -102,7 +102,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{} @@ -139,7 +139,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 93b46d2f..2e02a786 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -97,7 +97,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{} @@ -135,7 +135,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) return &external{ - db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, false), 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..169406e9 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{} @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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..d2af9672 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{} @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Grant) ( return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 0ebdc073..4f160f3d 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{} @@ -117,7 +117,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.User) (m } return &external{ - db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog), + db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false), 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 { From 8fe30e1bec384abe71256362e894a93bfe2afb4c Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 03:45:09 +0200 Subject: [PATCH 04/11] feat: inject AWS IAM auth token in cluster reconcilers Add awsiam.Inject (load AWS config, resolve region, generate and inject the IAM token) and call it from each cluster reconciler's Connect() when the credentials source is AWSIAMAuth. For MySQL this also enables the cleartext flag and ensures TLS is on; for PostgreSQL it forces sslmode=require. Certificate verification relies on the operator supplying the RDS CA (e.g. via trust-manager or a mounted secret). Part of #347. Signed-off-by: michielvha --- go.mod | 12 +++++ go.sum | 24 +++++++++ pkg/clients/awsiam/awsiam.go | 15 ++++++ pkg/clients/mysql/mysql.go | 13 +++++ .../cluster/mysql/database/reconciler.go | 21 +++++++- .../cluster/mysql/database/reconciler_test.go | 52 +++++++++++++++++++ .../cluster/mysql/grant/reconciler.go | 21 +++++++- .../cluster/mysql/user/reconciler.go | 21 +++++++- .../cluster/postgresql/database/reconciler.go | 19 ++++++- .../postgresql/database/reconciler_test.go | 49 +++++++++++++++++ .../default_privileges/reconciler.go | 17 +++++- .../postgresql/extension/reconciler.go | 19 ++++++- .../cluster/postgresql/grant/reconciler.go | 17 +++++- .../cluster/postgresql/role/reconciler.go | 17 +++++- .../cluster/postgresql/schema/reconciler.go | 17 +++++- 15 files changed, 324 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 81677f8a..71932a99 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 @@ -34,6 +35,17 @@ 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 diff --git a/go.sum b/go.sum index 9584b2bc..e62eb2bc 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,32 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew 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= diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go index 727f9d26..1fc4dad5 100644 --- a/pkg/clients/awsiam/awsiam.go +++ b/pkg/clients/awsiam/awsiam.go @@ -24,6 +24,7 @@ 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" xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/pkg/errors" @@ -37,6 +38,7 @@ const ( errMissingConnDetails = "connection secret must contain endpoint, port and username for AWS IAM authentication" errNoRegion = "AWS region could not be resolved: set spec.credentials.region, a \"region\" key in the connection secret, or the controller's AWS region (e.g. AWS_REGION)" errBuildToken = "cannot generate AWS RDS IAM authentication token" + errLoadConfig = "cannot load AWS configuration" ) // TokenBuilder has the same signature as auth.BuildAuthToken from the AWS SDK. @@ -91,3 +93,16 @@ func InjectToken(ctx context.Context, creds map[string][]byte, region string, creds[xpv1.ResourceCredentialsSecretPasswordKey] = []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 the entry point a +// reconciler's Connect() calls 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/mysql/mysql.go b/pkg/clients/mysql/mysql.go index 18bb0375..6e9d8266 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -47,6 +47,19 @@ func New(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsq } } +// EnsureTLS returns a TLS mode that guarantees an encrypted connection, for use +// with AWS IAM authentication. If mode is unset or non-encrypting +// ("preferred"/"false") it returns "skip-verify" (encrypted but unverified); +// otherwise it returns mode unchanged so an operator-configured verifying mode +// ("true"/"custom", backed by the RDS CA) is preserved. +func EnsureTLS(mode *string) *string { + if mode == nil || *mode == "false" || *mode == "preferred" { + skipVerify := "skip-verify" + return &skipVerify + } + return mode +} + // DSN returns the DSN URL. When cleartext is true, allowCleartextPasswords=true // is appended (required for AWS RDS IAM authentication). func DSN(username, password, endpoint, port, tls string, binlog *bool, cleartext bool) string { diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index 2392a1d3..f41c6ba5 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -36,6 +36,7 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" @@ -48,6 +49,8 @@ const ( errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectDB = "cannot select database" errCreateDB = "cannot create database" errDropDB = "cannot drop database" @@ -91,6 +94,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -131,7 +138,19 @@ 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, tlsName, mg.Spec.ForProvider.BinLog, false)}, nil + + cleartext := false + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + cleartext = true + // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via + // trust-manager or a mounted secret); honour a verifying tls mode if set, + // otherwise fall back to an encrypted (unverified) connection. + tlsName = mysql.EnsureTLS(tlsName) + } + 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 3192582e..7c543fc3 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -66,6 +66,14 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Stub the AWS IAM token injection so the success case needs no AWS access. + origInject := injectIAM + injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + defer func() { injectIAM = origInject }() + type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error @@ -161,6 +169,50 @@ func TestConnect(t *testing.T) { }, want: errors.Wrap(errBoom, errGetSecret), }, + "SuccessAWSIAMAuth": { + reason: "When source is AWSIAMAuth, a token is injected as the password, cleartext is enabled and TLS is forced.", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + o.Spec.Credentials.ConnectionSecretRef = &xpv1.SecretReference{Namespace: "ns", Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + }, + track: nopUsage, + newDB: func(creds map[string][]byte, tls *string, _ *bool, cleartext bool) xsql.DB { + if !cleartext { + t.Error("expected cleartext=true for AWS IAM auth") + } + if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("expected injected token as password, got %q", got) + } + if tls == nil || *tls != "skip-verify" { + t.Errorf("expected TLS forced to skip-verify, got %v", tls) + } + return mockDB{} + }, + }, + args: args{ + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + }, + }, + want: nil, + }, } for name, tc := range cases { diff --git a/pkg/controller/cluster/mysql/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index 920ad6c9..f60c9d2f 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -40,6 +40,7 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" @@ -52,6 +53,8 @@ const ( errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errCreateGrant = "cannot create grant" errRevokeGrant = "cannot revoke grant" errCurrentGrant = "cannot show current grants" @@ -99,6 +102,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -139,7 +146,19 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, nil + + cleartext := false + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + cleartext = true + // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via + // trust-manager or a mounted secret); honour a verifying tls mode if set, + // otherwise fall back to an encrypted (unverified) connection. + tlsName = mysql.EnsureTLS(tlsName) + } + 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/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 2e02a786..767e6c22 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -39,6 +39,7 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" @@ -51,6 +52,8 @@ const ( errGetSecret = "cannot get credentials Secret" errTLSConfig = "cannot load TLS config" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectUser = "cannot select user" errCreateUser = "cannot create user" errDropUser = "cannot drop user" @@ -94,6 +97,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -134,8 +141,20 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + + cleartext := false + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + cleartext = true + // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via + // trust-manager or a mounted secret); honour a verifying tls mode if set, + // otherwise fall back to an encrypted (unverified) connection. + tlsName = mysql.EnsureTLS(tlsName) + } return &external{ - db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, false), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext), kube: c.kube, }, nil } diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 362d7de7..10af0a29 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -42,6 +42,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -52,6 +53,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectDB = "cannot select database" errSelectServerVer = "cannot determine PostgreSQL server version" errCreateDB = "cannot create database" @@ -98,6 +101,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -132,7 +139,17 @@ 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 := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + // IAM auth requires TLS. Cert verification depends on the operator + // supplying the RDS CA (e.g. via trust-manager or a mounted secret). + sslMode = "require" + } + 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..186ed781 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -73,6 +73,14 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Stub the AWS IAM token injection so the success case needs no AWS access. + origInject := injectIAM + injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + defer func() { injectIAM = origInject }() + type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error @@ -168,6 +176,47 @@ 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 forced to require.", + 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 = &xpv1.SecretReference{Namespace: "ns", Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), + xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + }, + track: nopUsage, + newDB: func(creds map[string][]byte, _ string, sslmode string) xsql.DB { + if sslmode != "require" { + t.Errorf("expected sslmode forced to require, got %q", sslmode) + } + if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("expected injected token as password, got %q", got) + } + return mockDB{} + }, + }, + args: args{ + mg: &v1alpha1.Database{ + Spec: v1alpha1.DatabaseSpec{ + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + }, + }, + want: nil, + }, } 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..7c38490f 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -40,6 +40,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -50,6 +51,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectDefaultPrivileges = "cannot select default privileges" errCreateDefaultPrivileges = "cannot create default privileges" errRevokeDefaultPrivileges = "cannot revoke default privileges" @@ -96,6 +99,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -135,7 +142,15 @@ 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 := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + sslMode = "require" + } + 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..2d84f776 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -38,6 +38,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -48,6 +49,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectExtension = "cannot select extension" errCreateExtension = "cannot create extension" errDropExtension = "cannot drop extension" @@ -88,6 +91,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -123,13 +130,21 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + sslMode := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + sslMode = "require" + } + // 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..3943f505 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -41,6 +41,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -51,6 +52,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errNotGrant = "managed resource is not a Grant custom resource" errSelectGrant = "cannot select grant" errCreateGrant = "cannot create grant" @@ -79,6 +82,10 @@ const ( versionUnknown = 0 ) +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client log logging.Logger @@ -129,7 +136,15 @@ 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 := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + sslMode = "require" + } + 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/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 5528967d..d5c83a97 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -45,6 +45,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -55,6 +56,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectRole = "cannot select role" errGetRoleFailed = "cannot get role" errCreateRole = "cannot create role" @@ -100,6 +103,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client apiReader client.Reader @@ -135,8 +142,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + + sslMode := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + sslMode = "require" + } 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, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 7d616c9c..adf04750 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -40,6 +40,7 @@ import ( "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) @@ -50,6 +51,8 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token" + errSelectSchema = "cannot select schema" errCreateSchema = "cannot create schema" errDropSchema = "cannot drop schema" @@ -94,6 +97,10 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { var _ managed.TypedExternalConnector[*v1alpha1.Schema] = &connector{} +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -130,7 +137,15 @@ 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 := clients.ToString(pc.Spec.SSLMode) + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + return nil, errors.Wrap(err, errGenerateIAMToken) + } + sslMode = "require" + } + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode)}, nil } var _ managed.TypedExternalClient[*v1alpha1.Schema] = &external{} From 4c251c2ba3a36780c1f136a16c1b2dc21a61f137 Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 03:49:07 +0200 Subject: [PATCH 05/11] feat: inject AWS IAM auth token in namespaced providers Inject the IAM token centrally in GetProviderConfig() for the namespaced MySQL and PostgreSQL providers when the credentials source is AWSIAMAuth. MySQL additionally surfaces a Cleartext flag (forwarded by its three reconcilers) and forces TLS on; PostgreSQL forces sslmode=require. The six namespaced PostgreSQL reconcilers need no change. Part of #347. Signed-off-by: michielvha --- pkg/controller/namespaced/errors/errors.go | 11 +++ .../namespaced/mysql/database/reconciler.go | 2 +- .../namespaced/mysql/grant/reconciler.go | 2 +- .../namespaced/mysql/provider/provider.go | 32 ++++++- .../mysql/provider/provider_test.go | 90 +++++++++++++++++++ .../namespaced/mysql/user/reconciler.go | 2 +- .../postgresql/provider/provider.go | 25 +++++- .../postgresql/provider/provider_test.go | 87 ++++++++++++++++++ 8 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 pkg/controller/namespaced/mysql/provider/provider_test.go create mode 100644 pkg/controller/namespaced/postgresql/provider/provider_test.go diff --git a/pkg/controller/namespaced/errors/errors.go b/pkg/controller/namespaced/errors/errors.go index 1d9dc7a3..7d026215 100644 --- a/pkg/controller/namespaced/errors/errors.go +++ b/pkg/controller/namespaced/errors/errors.go @@ -10,6 +10,7 @@ const ( errGetSecret = "cannot get credentials Secret: %s" errInvalidProviderConfigKind = "invalid ProviderConfig kind: %s" errNoSecretRef = "providerConfig does not reference a credentials Secret" + errGenerateIAMToken = "cannot generate AWS IAM authentication token: %s" ) func GetProviderConfigError(err error) error { return ErrGetProviderConfig{err} } @@ -50,6 +51,16 @@ func (e ErrInvalidProviderConfigKind) Error() string { return fmt.Sprintf(errInvalidProviderConfigKind, e.kind) } +func GenerateIAMTokenError(err error) error { return ErrGenerateIAMToken{err} } + +type ErrGenerateIAMToken struct{ error } + +func (e ErrGenerateIAMToken) Error() string { + return fmt.Sprintf(errGenerateIAMToken, e.error) +} + +func (e ErrGenerateIAMToken) Unwrap() error { return e.error } + func MissingSecretRefError() error { return ErrNoSecretRef{} } type ErrNoSecretRef struct{} diff --git a/pkg/controller/namespaced/mysql/database/reconciler.go b/pkg/controller/namespaced/mysql/database/reconciler.go index 169406e9..d9791728 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler.go +++ b/pkg/controller/namespaced/mysql/database/reconciler.go @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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.go b/pkg/controller/namespaced/mysql/grant/reconciler.go index d2af9672..f4b7e539 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler.go @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Grant) ( return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false)}, 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/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 908dfc80..35427dd7 100644 --- a/pkg/controller/namespaced/mysql/provider/provider.go +++ b/pkg/controller/namespaced/mysql/provider/provider.go @@ -9,6 +9,8 @@ import ( "context" "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" @@ -20,14 +22,23 @@ type ProviderInfo struct { SecretData map[string][]byte TLS *string TLSConfig *v1alpha1.TLSConfig + // Cleartext is true when the connection uses AWS IAM auth, requiring the + // MySQL client to set allowCleartextPasswords=true. + Cleartext bool } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + 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 + source v1alpha1.MySQLConnectionSecretSource + region *string ) switch mg.GetProviderConfigReference().Kind { @@ -50,6 +61,8 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode tlsMode = providerConfig.Spec.TLS tlsConfig = providerConfig.Spec.TLSConfig keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = providerConfig.Spec.Credentials.Source + region = providerConfig.Spec.Credentials.Region case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ @@ -69,6 +82,8 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode tlsMode = clusterProviderConfig.Spec.TLS tlsConfig = clusterProviderConfig.Spec.TLSConfig keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = clusterProviderConfig.Spec.Credentials.Source + region = clusterProviderConfig.Spec.Credentials.Region default: return ProviderInfo{}, errors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) @@ -84,10 +99,25 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode return ProviderInfo{}, errors.GetSecretError(err) } + secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) + + cleartext := false + if source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, region, secretData); err != nil { + return ProviderInfo{}, errors.GenerateIAMTokenError(err) + } + cleartext = true + // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via + // trust-manager or a mounted secret); honour a verifying tls mode if set, + // otherwise fall back to an encrypted (unverified) connection. + tlsMode = mysql.EnsureTLS(tlsMode) + } + return ProviderInfo{ ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), + SecretData: secretData, TLS: tlsMode, TLSConfig: tlsConfig, + Cleartext: cleartext, }, nil } diff --git a/pkg/controller/namespaced/mysql/provider/provider_test.go b/pkg/controller/namespaced/mysql/provider/provider_test.go new file mode 100644 index 00000000..40afd29e --- /dev/null +++ b/pkg/controller/namespaced/mysql/provider/provider_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "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/apis/common" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" +) + +// TestGetProviderConfigAWSIAMAuth verifies the centralized IAM injection: when +// the source is AWSIAMAuth the token is injected as the password, the Cleartext +// flag is set, and TLS is forced on. This covers all three namespaced MySQL +// reconcilers, which obtain credentials exclusively through GetProviderConfig. +func TestGetProviderConfigAWSIAMAuth(t *testing.T) { + // Stub the AWS IAM token injection so the test needs no AWS access. + origInject := injectIAM + injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + defer func() { injectIAM = origInject }() + + 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 = xpv1.LocalSecretReference{Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + } + + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &common.ProviderConfigReference{ + Kind: v1alpha1.ProviderConfigKind, + Name: "example", + }, + }, + }, + } + + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatalf("GetProviderConfig(...): unexpected error: %v", err) + } + if !info.Cleartext { + t.Error("expected Cleartext=true for AWS IAM auth") + } + if got := string(info.SecretData[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("expected injected token as password, got %q", got) + } + if info.TLS == nil || *info.TLS != "skip-verify" { + t.Errorf("expected TLS forced to skip-verify, got %v", info.TLS) + } +} diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 4f160f3d..72c4b94b 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -117,7 +117,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.User) (m } return &external{ - db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, false), + db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext), kube: c.kube, }, nil } diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index 93652601..f98a87f9 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -9,6 +9,7 @@ import ( "context" "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" provErrors "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" @@ -22,12 +23,18 @@ type ProviderInfo struct { SSLMode *string } +// injectIAM generates and injects an AWS IAM auth token. It is a package +// variable so tests can replace it with a stub. +var injectIAM = awsiam.Inject + func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( secretKey *client.ObjectKey defaultDatabase string sslMode *string keyMapping map[string]string + source v1alpha1.PostgreSQLConnectionSource + region *string ) switch mg.GetProviderConfigReference().Kind { @@ -51,6 +58,8 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode defaultDatabase = providerConfig.Spec.DefaultDatabase sslMode = providerConfig.Spec.SSLMode keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = providerConfig.Spec.Credentials.Source + region = providerConfig.Spec.Credentials.Region case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ ObjectMeta: metav1.ObjectMeta{ @@ -70,6 +79,8 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode defaultDatabase = clusterProviderConfig.Spec.DefaultDatabase sslMode = clusterProviderConfig.Spec.SSLMode keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() + source = clusterProviderConfig.Spec.Credentials.Source + region = clusterProviderConfig.Spec.Credentials.Region default: return ProviderInfo{}, provErrors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) } @@ -84,9 +95,21 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode return ProviderInfo{}, provErrors.GetSecretError(err) } + secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) + + if source == v1alpha1.CredentialsSourceAWSIAMAuth { + if err := injectIAM(ctx, region, secretData); err != nil { + return ProviderInfo{}, provErrors.GenerateIAMTokenError(err) + } + // IAM auth requires TLS. Cert verification depends on the operator + // supplying the RDS CA (e.g. via trust-manager or a mounted secret). + requireMode := "require" + sslMode = &requireMode + } + return ProviderInfo{ ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: xsql.RemapCredentialKeys(s.Data, keyMapping), + SecretData: secretData, DefaultDatabase: defaultDatabase, SSLMode: sslMode, }, 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..f2039a65 --- /dev/null +++ b/pkg/controller/namespaced/postgresql/provider/provider_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "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/apis/common" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" +) + +// TestGetProviderConfigAWSIAMAuth verifies the centralized IAM injection: when +// the source is AWSIAMAuth the token is injected as the password and sslmode is +// forced to require. This covers all six namespaced PostgreSQL reconcilers, +// which obtain credentials exclusively through GetProviderConfig. +func TestGetProviderConfigAWSIAMAuth(t *testing.T) { + // Stub the AWS IAM token injection so the test needs no AWS access. + origInject := injectIAM + injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { + creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") + return nil + } + defer func() { injectIAM = origInject }() + + 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 = xpv1.LocalSecretReference{Name: "s"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), + xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + } + } + return nil + }), + } + + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &common.ProviderConfigReference{ + Kind: v1alpha1.ProviderConfigKind, + Name: "example", + }, + }, + }, + } + + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatalf("GetProviderConfig(...): unexpected error: %v", err) + } + if info.SSLMode == nil || *info.SSLMode != "require" { + t.Errorf("expected SSLMode forced to require, got %v", info.SSLMode) + } + if got := string(info.SecretData[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { + t.Errorf("expected injected token as password, got %q", got) + } +} From a65472dec30578ca13677136fff22f8af732c25c Mon Sep 17 00:00:00 2001 From: michielvha Date: Sat, 20 Jun 2026 03:51:12 +0200 Subject: [PATCH 06/11] docs: add AWS IAM auth examples and RDS CA setup guide Add example AWSIAMAuth ProviderConfigs for cluster and namespaced MySQL and PostgreSQL, and docs/aws-iam-auth.md covering prerequisites, the operator-supplied RDS CA trust options (trust-manager / mounted secret), region resolution and the TLS behaviour. Part of #347. Signed-off-by: michielvha --- docs/aws-iam-auth.md | 90 +++++++++++++++++++ examples/cluster/mysql/config_iam.yaml | 30 +++++++ examples/cluster/postgresql/config_iam.yaml | 30 +++++++ examples/namespaced/mysql/config_iam.yaml | 15 ++++ .../namespaced/postgresql/config_iam.yaml | 17 ++++ 5 files changed, 182 insertions(+) create mode 100644 docs/aws-iam-auth.md create mode 100644 examples/cluster/mysql/config_iam.yaml create mode 100644 examples/cluster/postgresql/config_iam.yaml create mode 100644 examples/namespaced/mysql/config_iam.yaml create mode 100644 examples/namespaced/postgresql/config_iam.yaml diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md new file mode 100644 index 00000000..67e79ea2 --- /dev/null +++ b/docs/aws-iam-auth.md @@ -0,0 +1,90 @@ +# AWS IAM database authentication + +provider-sql can authenticate to Amazon Aurora / RDS (MySQL and PostgreSQL) +using [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html) +instead of a static password. The provider generates a short-lived IAM token at +connection time and uses it as the database password. Combined with EKS Pod +Identity or IRSA, this removes static database credentials entirely. + +Select it with the `AWSIAMAuth` credentials source on a `ProviderConfig`: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional (see "Region resolution") +``` + +See the runnable examples: `examples/{cluster,namespaced}/{mysql,postgresql}/config_iam.yaml`. + +## Prerequisites + +1. **Enable IAM authentication** on the Aurora cluster / RDS instance. + +2. **Create an IAM-enabled database user for the provider to connect as.** This + is a one-time bootstrap that needs a normal (password) connection, performed + out of band - the provider does not do it. Use a **dedicated provisioning + role, not the master user** (granting `rds_iam` to the Postgres master + permanently disables its password auth, and the MySQL plugin is fixed at + `CREATE USER` time): + + - PostgreSQL: `CREATE USER crossplane_admin; GRANT rds_iam TO crossplane_admin;` + plus the privileges it needs to manage your databases/roles/grants. + - MySQL: `CREATE USER 'crossplane_admin'@'%' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS';` + plus the required `GRANT`s. + + (Creating these users is also possible declaratively through provider-sql's + own `Role`/`Grant` resources from a password-based `ProviderConfig`.) + +3. **Allow the controller's IAM identity to connect** by attaching an + `rds-db:connect` policy for that database user, and give the provider pod that + identity via [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) + or [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). + Credentials are discovered from the environment by the AWS SDK. + +4. **Provide a connection secret** containing only `endpoint`, `port` and + `username` (no `password`). + +## TLS and the Amazon RDS certificate authority + +IAM authentication requires TLS - RDS rejects unencrypted connections. To +*verify* the server certificate, the client needs the Amazon RDS CA, which is +**not** in the default system trust store. provider-sql does **not** ship the RDS +CA bundle; making it available to the provider pod is an operator concern. Two +common approaches: + +- **[trust-manager](https://cert-manager.io/docs/trust/trust-manager/)** - add the + [Amazon RDS CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) + to the provider pod's trust store. Then set `tls: "true"` (MySQL) or + `sslMode: verify-full` (PostgreSQL) and the certificate verifies normally. +- **Mounted secret** - mount the RDS CA and reference it through the provider's + existing custom TLS mechanism (`tls: custom` + `tlsConfig` for MySQL; + `sslMode: verify-full` with the CA for PostgreSQL). + +If you do neither, IAM auth still works but the connection is **encrypted +without certificate verification**: + +| Engine | Verified (RDS CA available) | Default fallback | +|--------|-----------------------------|------------------| +| MySQL | `tls: "true"` or `tls: custom` | `skip-verify` (encrypted, unverified) | +| PostgreSQL | `sslMode: verify-full` | `sslMode: require` (encrypted, unverified) | + +The provider always enforces at least an encrypted connection for IAM auth. + +## Region resolution + +The region used to sign the token is resolved in order: + +1. `spec.credentials.region` on the `ProviderConfig` +2. a `region` key in the connection secret +3. the controller's AWS environment (`AWS_REGION` / instance metadata) + +If none is set the token cannot be signed correctly, so configure one of them. + +## Notes + +- The provider opens a fresh connection each reconcile and generates a new token + each time, so the 15-minute token lifetime is never an issue. +- Setting `AWSIAMAuth` forces TLS on even if a weaker `tls`/`sslMode` was set. diff --git a/examples/cluster/mysql/config_iam.yaml b/examples/cluster/mysql/config_iam.yaml new file mode 100644 index 00000000..c867d74a --- /dev/null +++ b/examples/cluster/mysql/config_iam.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # tls "true" verifies the server certificate against the pod trust store. The + # Amazon RDS CA must be made available to the provider pod (see + # docs/aws-iam-auth.md), for example with trust-manager. Use tls: custom to + # point at a mounted RDS CA secret instead. If TLS is left unset, IAM auth + # falls back to an encrypted but unverified connection. + tls: "true" +--- +# The connection secret holds only endpoint, port and username - no password. +# The provider generates a short-lived IAM token and uses it as the password. +# apiVersion: v1 +# kind: Secret +# metadata: +# name: aurora-conn +# namespace: crossplane-system +# stringData: +# endpoint: my-cluster.cluster-xxxxxxxx.us-east-1.rds.amazonaws.com +# port: "3306" +# username: crossplane_admin diff --git a/examples/cluster/postgresql/config_iam.yaml b/examples/cluster/postgresql/config_iam.yaml new file mode 100644 index 00000000..ad9b364c --- /dev/null +++ b/examples/cluster/postgresql/config_iam.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam +spec: + # defaultDatabase: postgres + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # IAM auth forces sslMode=require (the connection is encrypted but the server + # certificate is not verified). For full verification set sslMode: verify-full + # and make the Amazon RDS CA available to the provider pod (see + # docs/aws-iam-auth.md), for example with trust-manager. + sslMode: require +--- +# The connection secret holds only endpoint, port and username - no password. +# The provider generates a short-lived IAM token and uses it as the password. +# apiVersion: v1 +# kind: Secret +# metadata: +# name: aurora-conn +# namespace: crossplane-system +# stringData: +# endpoint: my-cluster.cluster-xxxxxxxx.us-east-1.rds.amazonaws.com +# port: "5432" +# username: crossplane_admin diff --git a/examples/namespaced/mysql/config_iam.yaml b/examples/namespaced/mysql/config_iam.yaml new file mode 100644 index 00000000..df29d296 --- /dev/null +++ b/examples/namespaced/mysql/config_iam.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # See docs/aws-iam-auth.md for the RDS CA trust setup. tls: "true" verifies + # against the pod trust store; tls: custom points at a mounted RDS CA secret. + tls: "true" diff --git a/examples/namespaced/postgresql/config_iam.yaml b/examples/namespaced/postgresql/config_iam.yaml new file mode 100644 index 00000000..487e2278 --- /dev/null +++ b/examples/namespaced/postgresql/config_iam.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam + namespace: default +spec: + # defaultDatabase: postgres + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # IAM auth forces sslMode=require. For full server-cert verification set + # sslMode: verify-full and supply the Amazon RDS CA to the pod + # (see docs/aws-iam-auth.md). + sslMode: require From 8c0317331567e586c07d061405b58768caefb34d Mon Sep 17 00:00:00 2001 From: michielvha Date: Wed, 21 Oct 2026 17:20:11 +0200 Subject: [PATCH 07/11] fix: preserve IAM TLS verification and document CA mounts Signed-off-by: michielvha --- docs/aws-iam-auth.md | 113 ++++++++++--- .../aws-iam/deployment-runtime-config.yaml | 28 ++++ examples/cluster/mysql/config_iam.yaml | 7 +- examples/cluster/postgresql/config_iam.yaml | 8 +- examples/namespaced/mysql/config_iam.yaml | 4 +- .../namespaced/postgresql/config_iam.yaml | 7 +- pkg/clients/mysql/mysql.go | 3 +- pkg/clients/postgresql/postgresql.go | 10 ++ pkg/clients/postgresql/postgresql_test.go | 13 ++ pkg/clients/postgresql/tls_test.go | 151 ++++++++++++++++++ .../cluster/postgresql/database/reconciler.go | 5 +- .../postgresql/database/reconciler_test.go | 30 +++- .../default_privileges/reconciler.go | 2 +- .../postgresql/extension/reconciler.go | 2 +- .../cluster/postgresql/grant/reconciler.go | 2 +- .../cluster/postgresql/role/reconciler.go | 2 +- .../cluster/postgresql/schema/reconciler.go | 2 +- .../postgresql/provider/provider.go | 9 +- .../postgresql/provider/provider_test.go | 124 ++++++++------ 19 files changed, 420 insertions(+), 102 deletions(-) create mode 100644 examples/aws-iam/deployment-runtime-config.yaml create mode 100644 pkg/clients/postgresql/tls_test.go diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md index 67e79ea2..ae35f913 100644 --- a/docs/aws-iam-auth.md +++ b/docs/aws-iam-auth.md @@ -49,29 +49,96 @@ See the runnable examples: `examples/{cluster,namespaced}/{mysql,postgresql}/con ## TLS and the Amazon RDS certificate authority -IAM authentication requires TLS - RDS rejects unencrypted connections. To -*verify* the server certificate, the client needs the Amazon RDS CA, which is -**not** in the default system trust store. provider-sql does **not** ship the RDS -CA bundle; making it available to the provider pod is an operator concern. Two -common approaches: - -- **[trust-manager](https://cert-manager.io/docs/trust/trust-manager/)** - add the - [Amazon RDS CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) - to the provider pod's trust store. Then set `tls: "true"` (MySQL) or - `sslMode: verify-full` (PostgreSQL) and the certificate verifies normally. -- **Mounted secret** - mount the RDS CA and reference it through the provider's - existing custom TLS mechanism (`tls: custom` + `tlsConfig` for MySQL; - `sslMode: verify-full` with the CA for PostgreSQL). - -If you do neither, IAM auth still works but the connection is **encrypted -without certificate verification**: - -| Engine | Verified (RDS CA available) | Default fallback | -|--------|-----------------------------|------------------| -| MySQL | `tls: "true"` or `tls: custom` | `skip-verify` (encrypted, unverified) | -| PostgreSQL | `sslMode: verify-full` | `sslMode: require` (encrypted, unverified) | - -The provider always enforces at least an encrypted connection for IAM auth. +IAM authentication requires TLS. Encryption and server verification are separate: + +| Engine | Server verification enabled | Encrypted without server verification | +|--------|-----------------------------|---------------------------------------| +| MySQL | `tls: "true"` with a trusted RDS CA | `tls: skip-verify` | +| PostgreSQL | `sslMode: verify-full` with a trusted RDS CA | `sslMode: require` (normally) | + +For IAM, PostgreSQL preserves `require`, `verify-ca` and `verify-full`, and upgrades +`disable` or an empty mode to `require`. The Kubernetes default remains +`verify-full`, so an omitted field normally requires a trusted RDS CA. +`verify-ca` validates the certificate chain but does not check the hostname; +`verify-full` checks both. lib/pq can also perform CA-only verification in +`require` mode when a root certificate file is configured or discovered; select +`verify-full` explicitly for hostname verification. + +For MySQL, IAM upgrades an omitted mode, `false`, or `preferred` to `skip-verify`. +It preserves `true` and registered custom configurations. The existing custom +TLS loader requires **all three** of `caCert`, `clientCert`, and `clientKey`; +it does not support a CA-only `tlsConfig`. IAM tokens do not provide client +certificates. PostgreSQL has no equivalent CA Secret selector in ProviderConfig. +Use the shared pod trust bundle below for both engines. + +### Mount a trust bundle into the provider pod + +The provider does not ship the RDS CA. Create a bundle containing your normal +public roots **and** the +[Amazon RDS CA certificates](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem). +Public roots are needed for HTTPS connections to AWS services such as STS. +The following commands run on your workstation, not inside the distroless +provider container. Set `PUBLIC_CA_BUNDLE` to your maintained PEM bundle of public +roots (the shown path is common on Debian/Ubuntu): + +```sh +PUBLIC_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +curl --fail --silent --show-error \ + https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ + --output rds-global-bundle.pem +cat "$PUBLIC_CA_BUNDLE" rds-global-bundle.pem > provider-sql-ca-bundle.pem +kubectl -n crossplane-system create configmap provider-sql-ca \ + --from-file=ca-bundle.pem=provider-sql-ca-bundle.pem \ + --dry-run=client -o yaml | kubectl apply --server-side -f - +kubectl apply -f examples/aws-iam/deployment-runtime-config.yaml +``` + +Use the namespace where the provider pod runs if it differs from +`crossplane-system`. The certificates are public; a ConfigMap is sufficient. +Server-side apply avoids the client-side last-applied annotation size limit for +large combined bundles. +The runtime configuration mounts this ConfigMap read-only at +`/etc/provider-sql/ca/ca-bundle.pem` and sets `SSL_CERT_FILE` to that path. +[Go uses this variable when loading system roots](https://pkg.go.dev/crypto/x509#SystemCertPool). +No changes to the container image or PostgreSQL ProviderConfig API are needed. + +Attach the runtime configuration to your **existing** package Provider (adjust +`provider-sql` to its installed name): + +```sh +kubectl patch providers.pkg.crossplane.io provider-sql --type=merge \ + -p '{"spec":{"runtimeConfigRef":{"name":"provider-sql-aws-iam"}}}' +``` + +Each Provider has one runtimeConfigRef. If you already use a DeploymentRuntimeConfig +for workload identity, scheduling or other settings, merge this example's env, +volume and volumeMount into that configuration instead of replacing its reference. +Keep your existing IRSA or Pod Identity setup. The container name must be +`package-runtime` so Crossplane merges these settings into the provider container. +See [Crossplane runtime configuration](https://docs.crossplane.io/latest/packages/providers/#runtime-configuration). + +Then select `sslMode: verify-full` for PostgreSQL or `tls: "true"` for MySQL, as +shown in the cluster and namespaced IAM examples. Use the actual RDS endpoint +hostname, which must match the server certificate. A missing or untrusted CA +must cause a verified connection to fail; the provider does not silently disable +verification. + +Maintain the bundle as public roots and RDS CAs rotate. After updating its +ConfigMap, restart the provider Deployment so Go reloads the trust roots: + +```sh +PROVIDER_DEPLOYMENT=your-provider-deployment-name +kubectl -n crossplane-system rollout restart deployment/"$PROVIDER_DEPLOYMENT" +kubectl -n crossplane-system rollout status deployment/"$PROVIDER_DEPLOYMENT" +``` + +[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can automate +bundle distribution: combine `useDefaultCAs: true` with an RDS CA source and +publish the result to a ConfigMap named `provider-sql-ca`, key `ca-bundle.pem`, +in the provider's namespace. It does **not** mount the bundle or update a running +process's trust store. The same runtime configuration and restart steps still +apply. Keep the RDS source current; trust-manager does not fetch the AWS URL for +you. ## Region resolution diff --git a/examples/aws-iam/deployment-runtime-config.yaml b/examples/aws-iam/deployment-runtime-config.yaml new file mode 100644 index 00000000..da55dd1d --- /dev/null +++ b/examples/aws-iam/deployment-runtime-config.yaml @@ -0,0 +1,28 @@ +# First create provider-sql-ca in the provider pod's namespace; see docs/aws-iam-auth.md. +# Merge into an existing runtime config when preserving workload identity settings. +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: provider-sql-aws-iam +spec: + deploymentTemplate: + spec: + selector: {} + template: + spec: + containers: + - name: package-runtime + env: + - name: SSL_CERT_FILE + value: /etc/provider-sql/ca/ca-bundle.pem + volumeMounts: + - name: database-ca + mountPath: /etc/provider-sql/ca + readOnly: true + volumes: + - name: database-ca + configMap: + name: provider-sql-ca + items: + - key: ca-bundle.pem + path: ca-bundle.pem diff --git a/examples/cluster/mysql/config_iam.yaml b/examples/cluster/mysql/config_iam.yaml index c867d74a..c215a89c 100644 --- a/examples/cluster/mysql/config_iam.yaml +++ b/examples/cluster/mysql/config_iam.yaml @@ -10,11 +10,8 @@ spec: namespace: crossplane-system name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # tls "true" verifies the server certificate against the pod trust store. The - # Amazon RDS CA must be made available to the provider pod (see - # docs/aws-iam-auth.md), for example with trust-manager. Use tls: custom to - # point at a mounted RDS CA secret instead. If TLS is left unset, IAM auth - # falls back to an encrypted but unverified connection. + # Verify the server certificate using the mounted CA bundle. + # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. tls: "true" --- # The connection secret holds only endpoint, port and username - no password. diff --git a/examples/cluster/postgresql/config_iam.yaml b/examples/cluster/postgresql/config_iam.yaml index ad9b364c..c689dde8 100644 --- a/examples/cluster/postgresql/config_iam.yaml +++ b/examples/cluster/postgresql/config_iam.yaml @@ -11,11 +11,9 @@ spec: namespace: crossplane-system name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # IAM auth forces sslMode=require (the connection is encrypted but the server - # certificate is not verified). For full verification set sslMode: verify-full - # and make the Amazon RDS CA available to the provider pod (see - # docs/aws-iam-auth.md), for example with trust-manager. - sslMode: require + # Verify the server certificate and hostname using the mounted CA bundle. + # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. + sslMode: verify-full --- # The connection secret holds only endpoint, port and username - no password. # The provider generates a short-lived IAM token and uses it as the password. diff --git a/examples/namespaced/mysql/config_iam.yaml b/examples/namespaced/mysql/config_iam.yaml index df29d296..1fbec824 100644 --- a/examples/namespaced/mysql/config_iam.yaml +++ b/examples/namespaced/mysql/config_iam.yaml @@ -10,6 +10,6 @@ spec: connectionSecretRef: name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # See docs/aws-iam-auth.md for the RDS CA trust setup. tls: "true" verifies - # against the pod trust store; tls: custom points at a mounted RDS CA secret. + # Verify the server certificate using the mounted CA bundle. + # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. tls: "true" diff --git a/examples/namespaced/postgresql/config_iam.yaml b/examples/namespaced/postgresql/config_iam.yaml index 487e2278..0483b3de 100644 --- a/examples/namespaced/postgresql/config_iam.yaml +++ b/examples/namespaced/postgresql/config_iam.yaml @@ -11,7 +11,6 @@ spec: connectionSecretRef: name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # IAM auth forces sslMode=require. For full server-cert verification set - # sslMode: verify-full and supply the Amazon RDS CA to the pod - # (see docs/aws-iam-auth.md). - sslMode: require + # Verify the server certificate and hostname using the mounted CA bundle. + # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. + sslMode: verify-full diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index 6e9d8266..0655cb84 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -51,7 +51,8 @@ func New(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsq // with AWS IAM authentication. If mode is unset or non-encrypting // ("preferred"/"false") it returns "skip-verify" (encrypted but unverified); // otherwise it returns mode unchanged so an operator-configured verifying mode -// ("true"/"custom", backed by the RDS CA) is preserved. +// ("true" or a registered custom configuration) is preserved. Custom TLS +// configuration loading and certificate requirements are handled separately. func EnsureTLS(mode *string) *string { if mode == nil || *mode == "false" || *mode == "preferred" { skipVerify := "skip-verify" diff --git a/pkg/clients/postgresql/postgresql.go b/pkg/clients/postgresql/postgresql.go index 9dd60287..db3d1b38 100644 --- a/pkg/clients/postgresql/postgresql.go +++ b/pkg/clients/postgresql/postgresql.go @@ -46,6 +46,16 @@ func New(creds map[string][]byte, database, sslmode string) xsql.DB { } } +// EnsureTLS requires encryption without weakening configured verification. +// Kubernetes defaults an omitted sslMode to verify-full; empty handles callers +// that construct ProviderConfigs without API-server defaulting. +func EnsureTLS(mode string) string { + if mode == "" || mode == "disable" { + return "require" + } + return mode +} + // DSN returns the DSN URL func DSN(username, password, endpoint, port, database, sslmode string) string { // Use net/url UserPassword to encode the username and password diff --git a/pkg/clients/postgresql/postgresql_test.go b/pkg/clients/postgresql/postgresql_test.go index a2056b91..0746ffbd 100644 --- a/pkg/clients/postgresql/postgresql_test.go +++ b/pkg/clients/postgresql/postgresql_test.go @@ -17,3 +17,16 @@ func TestDSNURLEscaping(t *testing.T) { t.Errorf("DSN string did not match expected output with userinfo URL encoded") } } + +func TestEnsureTLS(t *testing.T) { + for _, tc := range []struct{ mode, want string }{ + {"", "require"}, {"disable", "require"}, {"require", "require"}, + {"verify-ca", "verify-ca"}, {"verify-full", "verify-full"}, {"invalid", "invalid"}, + } { + t.Run(tc.mode, func(t *testing.T) { + if got := EnsureTLS(tc.mode); got != tc.want { + t.Errorf("want %q, got %q", tc.want, got) + } + }) + } +} diff --git a/pkg/clients/postgresql/tls_test.go b/pkg/clients/postgresql/tls_test.go new file mode 100644 index 00000000..0c315847 --- /dev/null +++ b/pkg/clients/postgresql/tls_test.go @@ -0,0 +1,151 @@ +package postgresql + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "encoding/pem" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// Isolate Go's cached system roots so this test exercises the documented +// SSL_CERT_FILE mount mechanism without affecting other tests in the package. +func TestTLSSystemRoots(t *testing.T) { + if os.Getenv("PROVIDER_SQL_TLS_TEST_CHILD") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestTLSSystemRoots$", "-test.v", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_TLS_TEST_CHILD=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("TLS subprocess: %v\n%s", err, out) + } + return + } + dir := t.TempDir() + t.Setenv("SSL_CERT_FILE", filepath.Join(dir, "ca-bundle.pem")) + t.Setenv("SSL_CERT_DIR", dir) + t.Setenv("PGSSLROOTCERT", "") + t.Setenv("PGSSLCERT", "") + t.Setenv("PGSSLKEY", "") + t.Setenv("PGSERVICE", "") + t.Setenv("HOME", dir) + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("SSL_CERT_FILE"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, mode string + trusted, matching bool + want string + }{ + {"trusted matching", "verify-full", true, true, "TLS accepted"}, + {"untrusted", "verify-full", false, true, "unknown authority"}, + {"wrong hostname", "verify-full", true, false, "not 127.0.0.1"}, + {"CA only permits hostname mismatch", "verify-ca", true, false, "TLS accepted"}, + } { + t.Run(tc.name, func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ip := "127.0.0.1" + if !tc.matching { + ip = "127.0.0.2" + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP(ip)}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature} + parent, signer := ca, caKey + if !tc.trusted { + parent, signer = leaf, key + } + der, err := x509.CreateCertificate(rand.Reader, leaf, parent, &key.PublicKey, signer) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + request := make([]byte, 8) + if _, err = io.ReadFull(conn, request); err != nil { + return + } + if binary.BigEndian.Uint32(request[4:]) != 80877103 { + return + } + if _, err = conn.Write([]byte("S")); err != nil { + return + } + secure := tls.Server(conn, &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}) + if err = secure.Handshake(); err != nil { + return + } + // Wait for the client's startup message. verify-ca performs its manual + // certificate check after the TLS handshake and before sending startup. + length := make([]byte, 4) + if _, err = io.ReadFull(secure, length); err != nil { + return + } + size := binary.BigEndian.Uint32(length) + if size < 4 || size > 65536 { + return + } + if _, err = io.CopyN(io.Discard, secure, int64(size-4)); err != nil { + return + } + // A PostgreSQL startup error proves TLS verification completed. No real + // PostgreSQL server or database credentials are needed. + body := []byte("SFATAL\x00C28000\x00MTLS accepted\x00\x00") + message := make([]byte, 5) + message[0] = 'E' + binary.BigEndian.PutUint32(message[1:], uint32(len(body)+4)) + _, _ = secure.Write(append(message, body...)) + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + db := New(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", EnsureTLS(tc.mode)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var value int + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, &value) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("want error containing %q, got %v", tc.want, err) + } + _ = listener.Close() + <-done + }) + } +} diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 10af0a29..00c0cb9e 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -145,9 +145,8 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - // IAM auth requires TLS. Cert verification depends on the operator - // supplying the RDS CA (e.g. via trust-manager or a mounted secret). - sslMode = "require" + // Require encryption while preserving operator-configured verification. + sslMode = postgresql.EnsureTLS(sslMode) } return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode)}, nil } diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 186ed781..56285c24 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -177,7 +177,7 @@ 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 forced to require.", + reason: "When source is AWSIAMAuth, a token is injected as the password and an unset sslmode requires encryption.", fields: fields{ kube: &test.MockClient{ MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { @@ -219,6 +219,34 @@ func TestConnect(t *testing.T) { }, } + for _, mode := range []string{"", "disable", "require", "verify-ca", "verify-full"} { + tc := cases["SuccessAWSIAMAuth"] + baseGet := tc.fields.kube.(*test.MockClient).MockGet + tc.fields.kube = &test.MockClient{MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if err := baseGet(ctx, key, obj); err != nil { + return err + } + if pc, ok := obj.(*v1alpha1.ProviderConfig); ok { + pc.Spec.SSLMode = &mode + } + return nil + }} + wantMode := mode + if mode == "" || mode == "disable" { + wantMode = "require" + } + tc.fields.newDB = func(creds map[string][]byte, _ string, got string) xsql.DB { + if got != wantMode { + t.Errorf("IAM sslmode: want %q, got %q", wantMode, got) + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != "iam-token" { + t.Error("missing IAM token") + } + return mockDB{} + } + cases["AWSIAMAuth/"+mode] = tc + } + for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &connector{kube: tc.fields.kube, track: tc.fields.track, newDB: tc.fields.newDB} diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index 7c38490f..6ce3265a 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -148,7 +148,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - sslMode = "require" + sslMode = postgresql.EnsureTLS(sslMode) } return &external{db: c.newDB(secretData, database, sslMode)}, nil } diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index 2d84f776..8d9248e2 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -135,7 +135,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - sslMode = "require" + sslMode = postgresql.EnsureTLS(sslMode) } // We do not want to create an extension on the default DB diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index 3943f505..86b56429 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -142,7 +142,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - sslMode = "require" + sslMode = postgresql.EnsureTLS(sslMode) } xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode) diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index d5c83a97..8a462cb7 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -148,7 +148,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - sslMode = "require" + sslMode = postgresql.EnsureTLS(sslMode) } return &external{ db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode), diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index adf04750..90786754 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -143,7 +143,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } - sslMode = "require" + sslMode = postgresql.EnsureTLS(sslMode) } return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode)}, nil } diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index f98a87f9..87d32203 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -9,7 +9,9 @@ import ( "context" "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients" "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" provErrors "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/errors" @@ -101,10 +103,9 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode if err := injectIAM(ctx, region, secretData); err != nil { return ProviderInfo{}, provErrors.GenerateIAMTokenError(err) } - // IAM auth requires TLS. Cert verification depends on the operator - // supplying the RDS CA (e.g. via trust-manager or a mounted secret). - requireMode := "require" - sslMode = &requireMode + // Require encryption while preserving operator-configured verification. + mode := postgresql.EnsureTLS(clients.ToString(sslMode)) + sslMode = &mode } return ProviderInfo{ diff --git a/pkg/controller/namespaced/postgresql/provider/provider_test.go b/pkg/controller/namespaced/postgresql/provider/provider_test.go index f2039a65..93a5f74a 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider_test.go +++ b/pkg/controller/namespaced/postgresql/provider/provider_test.go @@ -18,70 +18,96 @@ package provider import ( "context" + "fmt" "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/apis/common" - xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" - xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" "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" ) -// TestGetProviderConfigAWSIAMAuth verifies the centralized IAM injection: when -// the source is AWSIAMAuth the token is injected as the password and sslmode is -// forced to require. This covers all six namespaced PostgreSQL reconcilers, -// which obtain credentials exclusively through GetProviderConfig. -func TestGetProviderConfigAWSIAMAuth(t *testing.T) { - // Stub the AWS IAM token injection so the test needs no AWS access. +// TestGetProviderConfigTLS covers both ProviderConfig kinds and ensures IAM +// enforces encryption without downgrading verification or changing password auth. +func TestGetProviderConfigTLS(t *testing.T) { origInject := injectIAM injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { - creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") + creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") return nil } - defer func() { injectIAM = origInject }() - - 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 = xpv1.LocalSecretReference{Name: "s"} - case *corev1.Secret: - o.Data = map[string][]byte{ - xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), - xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), - xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), - } + t.Cleanup(func() { injectIAM = origInject }) + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, iam := range []bool{false, true} { + for _, mode := range []string{"unset", "", "disable", "require", "verify-ca", "verify-full"} { + t.Run(fmt.Sprintf("%s/IAM=%t/%s", kind, iam, mode), func(t *testing.T) { + var sslMode *string + if mode != "unset" { + sslMode = &mode + } + source := v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.SSLMode = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + case *v1alpha1.ClusterProviderConfig: + o.Spec.SSLMode = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "s", Namespace: "default"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretPasswordKey: []byte("password"), + } + } + return nil + })} + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "example"}, + }}, + } + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + wantMode := mode + if mode == "unset" { + wantMode = "" + } + if iam && (wantMode == "" || wantMode == "disable") { + wantMode = "require" + } + gotMode := "" + if info.SSLMode != nil { + gotMode = *info.SSLMode + } + if gotMode != wantMode { + t.Errorf("SSLMode: want %q, got %q", wantMode, gotMode) + } + if !iam && mode == "unset" && info.SSLMode != nil { + t.Error("password auth should preserve nil SSLMode") + } + wantPassword := "password" + if iam { + wantPassword = "iam-token" + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != wantPassword { + t.Errorf("password: want %q, got %q", wantPassword, got) + } + }) } - return nil - }), - } - - mg := &v1alpha1.Database{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, - Spec: v1alpha1.DatabaseSpec{ - ManagedResourceSpec: xpv2.ManagedResourceSpec{ - ProviderConfigReference: &common.ProviderConfigReference{ - Kind: v1alpha1.ProviderConfigKind, - Name: "example", - }, - }, - }, - } - - info, err := GetProviderConfig(context.Background(), kube, mg) - if err != nil { - t.Fatalf("GetProviderConfig(...): unexpected error: %v", err) - } - if info.SSLMode == nil || *info.SSLMode != "require" { - t.Errorf("expected SSLMode forced to require, got %v", info.SSLMode) - } - if got := string(info.SecretData[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { - t.Errorf("expected injected token as password, got %q", got) + } } } From b525631d5725bbf73128072ec1c24ff4d7c60092 Mon Sep 17 00:00:00 2001 From: michielvha Date: Thu, 22 Oct 2026 09:06:05 +0200 Subject: [PATCH 08/11] fix: verify IAM TLS and refresh tokens per connection Signed-off-by: michielvha --- apis/cluster/mysql/v1alpha1/provider_types.go | 2 + .../mysql/v1alpha1/provider_types.go | 2 + docs/aws-iam-auth.md | 112 ++++-- .../cluster/mysql/config_iam_custom_ca.yaml | 20 ++ .../mysql/config_iam_custom_ca.yaml | 20 ++ pkg/clients/awsiam/awsiam.go | 115 +++++-- pkg/clients/awsiam/awsiam_test.go | 320 +++++++++++++----- pkg/clients/awsiam/config_test.go | 130 +++++++ pkg/clients/mysql/custom_tls_test.go | 165 +++++++++ pkg/clients/mysql/mysql.go | 63 ++-- pkg/clients/mysql/mysql_test.go | 43 +++ pkg/clients/mysql/tls_test.go | 186 ++++++++++ pkg/clients/postgresql/postgresql.go | 90 +++-- pkg/clients/postgresql/postgresql_test.go | 72 ++++ pkg/clients/postgresql/tls_test.go | 101 +++++- pkg/clients/xsql/password.go | 48 +++ pkg/clients/xsql/password_test.go | 166 +++++++++ .../cluster/mysql/database/reconciler.go | 25 +- .../cluster/mysql/database/reconciler_test.go | 39 ++- .../cluster/mysql/grant/reconciler.go | 25 +- .../cluster/mysql/grant/reconciler_test.go | 2 +- pkg/controller/cluster/mysql/tls/tls.go | 40 ++- .../cluster/mysql/user/reconciler.go | 25 +- .../cluster/mysql/user/reconciler_test.go | 2 +- .../cluster/postgresql/database/reconciler.go | 21 +- .../postgresql/database/reconciler_test.go | 44 ++- .../default_privileges/reconciler.go | 21 +- .../default_privileges/reconciler_test.go | 4 +- .../postgresql/extension/reconciler.go | 23 +- .../postgresql/extension/reconciler_test.go | 2 +- .../cluster/postgresql/grant/connect_test.go | 6 +- .../cluster/postgresql/grant/reconciler.go | 21 +- .../postgresql/grant/reconciler_test.go | 2 +- .../cluster/postgresql/role/reconciler.go | 21 +- .../postgresql/role/reconciler_test.go | 6 +- .../cluster/postgresql/schema/reconciler.go | 21 +- .../postgresql/schema/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 | 20 +- .../mysql/provider/provider_test.go | 143 +++++--- pkg/controller/namespaced/mysql/tls/tls.go | 40 ++- .../namespaced/mysql/user/reconciler.go | 6 +- .../namespaced/mysql/user/reconciler_test.go | 2 +- .../postgresql/database/reconciler.go | 6 +- .../postgresql/database/reconciler_test.go | 2 +- .../default_privileges/reconciler.go | 6 +- .../default_privileges/reconciler_test.go | 4 +- .../postgresql/extension/reconciler.go | 8 +- .../postgresql/extension/reconciler_test.go | 2 +- .../postgresql/grant/connect_test.go | 6 +- .../namespaced/postgresql/grant/reconciler.go | 6 +- .../postgresql/grant/reconciler_test.go | 2 +- .../postgresql/provider/provider.go | 13 +- .../postgresql/provider/provider_test.go | 25 +- .../namespaced/postgresql/role/reconciler.go | 6 +- .../postgresql/role/reconciler_test.go | 2 +- .../postgresql/schema/reconciler.go | 6 +- .../postgresql/schema/reconciler_test.go | 2 +- 61 files changed, 1846 insertions(+), 484 deletions(-) create mode 100644 examples/cluster/mysql/config_iam_custom_ca.yaml create mode 100644 examples/namespaced/mysql/config_iam_custom_ca.yaml create mode 100644 pkg/clients/awsiam/config_test.go create mode 100644 pkg/clients/mysql/custom_tls_test.go create mode 100644 pkg/clients/mysql/tls_test.go create mode 100644 pkg/clients/xsql/password.go create mode 100644 pkg/clients/xsql/password_test.go diff --git a/apis/cluster/mysql/v1alpha1/provider_types.go b/apis/cluster/mysql/v1alpha1/provider_types.go index 104fcc41..2846c714 100644 --- a/apis/cluster/mysql/v1alpha1/provider_types.go +++ b/apis/cluster/mysql/v1alpha1/provider_types.go @@ -42,6 +42,8 @@ type ProviderConfigSpec struct { } // TLSConfig defines the TLS configuration for the provider when tls=custom. +// CACert verifies the server. ClientCert and ClientKey are optional and must +// be supplied together when the server requires mutual TLS. type TLSConfig struct { CACert TLSSecret `json:"caCert,omitempty"` ClientCert TLSSecret `json:"clientCert,omitempty"` diff --git a/apis/namespaced/mysql/v1alpha1/provider_types.go b/apis/namespaced/mysql/v1alpha1/provider_types.go index 9283870a..6170ec94 100644 --- a/apis/namespaced/mysql/v1alpha1/provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/provider_types.go @@ -42,6 +42,8 @@ type ProviderConfigSpec struct { } // TLSConfig defines the TLS configuration for the provider when tls=custom. +// CACert verifies the server. ClientCert and ClientKey are optional and must +// be supplied together when the server requires mutual TLS. type TLSConfig struct { CACert TLSSecret `json:"caCert,omitempty"` ClientCert TLSSecret `json:"clientCert,omitempty"` diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md index ae35f913..a6b4202e 100644 --- a/docs/aws-iam-auth.md +++ b/docs/aws-iam-auth.md @@ -3,7 +3,7 @@ provider-sql can authenticate to Amazon Aurora / RDS (MySQL and PostgreSQL) using [IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html) instead of a static password. The provider generates a short-lived IAM token at -connection time and uses it as the database password. Combined with EKS Pod +each physical connection attempt and uses it as the database password. Combined with EKS Pod Identity or IRSA, this removes static database credentials entirely. Select it with the `AWSIAMAuth` credentials source on a `ProviderConfig`: @@ -26,9 +26,8 @@ See the runnable examples: `examples/{cluster,namespaced}/{mysql,postgresql}/con 2. **Create an IAM-enabled database user for the provider to connect as.** This is a one-time bootstrap that needs a normal (password) connection, performed out of band - the provider does not do it. Use a **dedicated provisioning - role, not the master user** (granting `rds_iam` to the Postgres master - permanently disables its password auth, and the MySQL plugin is fixed at - `CREATE USER` time): + role, not the master user**. In PostgreSQL, granting `rds_iam` gives IAM + authentication precedence over password authentication for that role: - PostgreSQL: `CREATE USER crossplane_admin; GRANT rds_iam TO crossplane_admin;` plus the privileges it needs to manage your databases/roles/grants. @@ -47,6 +46,19 @@ See the runnable examples: `examples/{cluster,namespaced}/{mysql,postgresql}/con 4. **Provide a connection secret** containing only `endpoint`, `port` and `username` (no `password`). +### IAM identity and ProviderConfig permissions + +All IAM ProviderConfigs use the provider pod's AWS identity. A namespace does +not give a ProviderConfig a separate IAM role. Anyone allowed to create an IAM +ProviderConfig and its connection Secret can select any database user that the +pod's `rds-db:connect` policy permits. + +Restrict that policy to the required database resource IDs and usernames, for +example `arn:aws:rds-db:eu-west-1:123456789012:dbuser:db-RESOURCEID/crossplane_admin`, +and restrict who can create or modify ProviderConfigs through Kubernetes RBAC. +Use separate provider installations and IAM roles for independent trust boundaries. +See [the RDS IAM policy format](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html). + ## TLS and the Amazon RDS certificate authority IAM authentication requires TLS. Encryption and server verification are separate: @@ -54,22 +66,32 @@ IAM authentication requires TLS. Encryption and server verification are separate | Engine | Server verification enabled | Encrypted without server verification | |--------|-----------------------------|---------------------------------------| | MySQL | `tls: "true"` with a trusted RDS CA | `tls: skip-verify` | -| PostgreSQL | `sslMode: verify-full` with a trusted RDS CA | `sslMode: require` (normally) | +| PostgreSQL | `sslMode: verify-full` with a trusted RDS CA | `sslMode: require` without an explicit root certificate file | For IAM, PostgreSQL preserves `require`, `verify-ca` and `verify-full`, and upgrades -`disable` or an empty mode to `require`. The Kubernetes default remains -`verify-full`, so an omitted field normally requires a trusted RDS CA. +`disable` to `require`. An omitted field defaults to `verify-full`, which requires +a trusted RDS CA. Kubernetes rejects an explicit empty mode. `verify-ca` validates the certificate chain but does not check the hostname; -`verify-full` checks both. lib/pq can also perform CA-only verification in -`require` mode when a root certificate file is configured or discovered; select -`verify-full` explicitly for hostname verification. - -For MySQL, IAM upgrades an omitted mode, `false`, or `preferred` to `skip-verify`. -It preserves `true` and registered custom configurations. The existing custom -TLS loader requires **all three** of `caCert`, `clientCert`, and `clientKey`; -it does not support a CA-only `tlsConfig`. IAM tokens do not provide client -certificates. PostgreSQL has no equivalent CA Secret selector in ProviderConfig. -Use the shared pod trust bundle below for both engines. +`verify-full` checks both. Use `verify-full` for IAM server identity verification: +with a broad public trust bundle, `verify-ca` can accept a certificate for a +different server. With the `SSL_CERT_FILE` mount below, `require` +remains encrypted but unverified: loading system roots does not enable +verification in that mode. lib/pq's compatibility behavior only enables CA-only +checking for `require` when a separate `sslrootcert` file is configured or +`~/.postgresql/root.crt` is discovered. `SSL_CERT_FILE` is not that setting. + +For MySQL, IAM uses `true` when `tls` is omitted or set to `preferred`. This +verifies the server certificate and hostname using the system pool. An absent +RDS CA therefore causes the default IAM connection to fail. Kubernetes rejects +explicit empty or `false` values. +Explicit `skip-verify` remains an operator opt-in to encrypted but unverified +connections. Registered custom configurations are preserved. + +MySQL also supports a per-ProviderConfig CA Secret with `tls: custom` and +`tlsConfig.caCert.secretRef`. No client certificate is needed for IAM. If using +mutual TLS, supply both `clientCert` and `clientKey`; a partial pair is rejected. +PostgreSQL has no equivalent CA Secret selector in ProviderConfig and uses the +pod trust bundle described below. ### Mount a trust bundle into the provider pod @@ -140,18 +162,68 @@ process's trust store. The same runtime configuration and restart steps still apply. Keep the RDS source current; trust-manager does not fetch the AWS URL for you. +### MySQL: use a CA Secret for one ProviderConfig + +This alternative keeps database CA trust separate from the pod's public roots. +It does not require `SSL_CERT_FILE` or a runtime mount. Create the CA Secret in +the namespace referenced by `tlsConfig`: + +```sh +kubectl -n crossplane-system create secret generic rds-ca \ + --from-file=ca-bundle.pem=rds-global-bundle.pem \ + --dry-run=client -o yaml | kubectl apply --server-side -f - +``` + +For a namespaced ProviderConfig, create the CA Secret in that config's namespace +and use the same namespace in `tlsConfig.caCert.secretRef` (the complete namespaced +example uses `default`). Existing TLS Secret selectors accept cross-namespace +references; they do not enforce tenant isolation. Restrict ProviderConfig writes +accordingly, especially when using client certificate and key Secrets. + +Then configure: + +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system # omit for a namespaced ProviderConfig + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem +``` + +Complete examples are in +`examples/{cluster,namespaced}/mysql/config_iam_custom_ca.yaml`. +Keep `insecureSkipVerify` at its default `false`. Update the Secret when the +RDS CA changes; the next reconcile loads the updated bundle. Each ProviderConfig +has its own registered TLS configuration, including when namespaced configs +share a name. + ## Region resolution The region used to sign the token is resolved in order: 1. `spec.credentials.region` on the `ProviderConfig` 2. a `region` key in the connection secret -3. the controller's AWS environment (`AWS_REGION` / instance metadata) +3. the AWS SDK's configured region (`AWS_REGION`, `AWS_DEFAULT_REGION`, or the + selected shared AWS profile) If none is set the token cannot be signed correctly, so configure one of them. +Region discovery from EC2 instance metadata is not enabled. ## Notes -- The provider opens a fresh connection each reconcile and generates a new token - each time, so the 15-minute token lifetime is never an issue. +- IAM tokens are valid for 15 minutes. The provider signs a fresh token immediately + before each physical database connection, including reconnects within a long + reconcile. An already authenticated session continues after its token expires; + it does not need to be interrupted or reauthenticated. The AWS credentials used + for signing must also be valid; the SDK refreshes renewable credential sources. + The provider reuses its AWS configuration and credential cache across reconciles; + restart it after changing environment variables or shared AWS profile settings. - Setting `AWSIAMAuth` forces TLS on even if a weaker `tls`/`sslMode` was set. diff --git a/examples/cluster/mysql/config_iam_custom_ca.yaml b/examples/cluster/mysql/config_iam_custom_ca.yaml new file mode 100644 index 00000000..514cc206 --- /dev/null +++ b/examples/cluster/mysql/config_iam_custom_ca.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam-custom-ca +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + namespace: crossplane-system + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # The CA is loaded from this Secret; no pod trust mount or client cert is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem diff --git a/examples/namespaced/mysql/config_iam_custom_ca.yaml b/examples/namespaced/mysql/config_iam_custom_ca.yaml new file mode 100644 index 00000000..56e97702 --- /dev/null +++ b/examples/namespaced/mysql/config_iam_custom_ca.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: aurora-iam-custom-ca + namespace: default +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn # endpoint, port, username - no password + # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env + # The CA is loaded from this Secret; no pod trust mount or client cert is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: default # keep the CA Secret in the ProviderConfig's namespace + key: ca-bundle.pem diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go index 1fc4dad5..2f7a7cdc 100644 --- a/pkg/clients/awsiam/awsiam.go +++ b/pkg/clients/awsiam/awsiam.go @@ -15,18 +15,22 @@ limitations under the License. */ // Package awsiam generates short-lived AWS RDS IAM authentication tokens and -// injects them in place of a static database password. It lets provider-sql +// supplies them in place of a static database password. It lets provider-sql // connect to Aurora/RDS using an IAM identity (EKS Pod Identity or IRSA) // instead of a password stored in a Kubernetes Secret. package awsiam import ( "context" + "net" + "sync" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/feature/rds/auth" - xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/pkg/errors" ) @@ -42,7 +46,7 @@ const ( ) // TokenBuilder has the same signature as auth.BuildAuthToken from the AWS SDK. -// InjectToken takes it as a parameter so production code passes the real +// The token provider takes it as a parameter so production code passes the real // auth.BuildAuthToken while tests pass a stub that returns a fixed token // without calling AWS. type TokenBuilder func(ctx context.Context, endpoint, region, dbUser string, @@ -63,46 +67,83 @@ func ResolveRegion(specRegion *string, creds map[string][]byte, cfgRegion string 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. -func InjectToken(ctx context.Context, creds map[string][]byte, region string, - awsCreds aws.CredentialsProvider, build TokenBuilder) error { - endpoint := string(creds[xpv1.ResourceCredentialsSecretEndpointKey]) - port := string(creds[xpv1.ResourceCredentialsSecretPortKey]) - username := string(creds[xpv1.ResourceCredentialsSecretUserKey]) +type awsConfigCache struct { + mu sync.Mutex + config *aws.Config + loading chan struct{} + load func(context.Context) (aws.Config, error) +} - if endpoint == "" || port == "" || username == "" { - return errors.New(errMissingConnDetails) - } - if region == "" { - return errors.New(errNoRegion) +func (c *awsConfigCache) get(ctx context.Context) (aws.Config, error) { + for { + if err := ctx.Err(); err != nil { + return aws.Config{}, err + } + c.mu.Lock() + if c.config != nil { + cfg := *c.config + c.mu.Unlock() + return cfg, nil + } + if c.loading != nil { + ready := c.loading + c.mu.Unlock() + select { + case <-ctx.Done(): + return aws.Config{}, ctx.Err() + case <-ready: + continue + } + } + c.loading = make(chan struct{}) + c.mu.Unlock() + + cfg, err := c.load(ctx) + c.mu.Lock() + if err == nil { + c.config = &cfg + } + close(c.loading) + c.loading = nil + c.mu.Unlock() + return cfg, err } +} - token, err := build(ctx, endpoint+":"+port, region, username, awsCreds) +var defaultConfigCache = awsConfigCache{load: func(ctx context.Context) (aws.Config, error) { + return config.LoadDefaultConfig(ctx) +}} + +// NewTokenProvider reuses the process's AWS configuration and signs a new token for +// every physical connection. The SDK credential provider refreshes temporary +// AWS credentials as needed. No token is retained in a long-lived DSN. +func NewTokenProvider(ctx context.Context, specRegion *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + cfg, err := defaultConfigCache.get(ctx) if err != nil { - return errors.Wrap(err, errBuildToken) + return nil, errors.Wrap(err, errLoadConfig) } - - creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte(token) - return nil + return tokenProvider(creds, ResolveRegion(specRegion, creds, cfg.Region), cfg.Credentials, auth.BuildAuthToken) } -// 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 the entry point a -// reconciler's Connect() calls 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) +func tokenProvider(creds map[string][]byte, region string, awsCreds aws.CredentialsProvider, build TokenBuilder) (xsql.PasswordProvider, error) { + endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) + port := string(creds[xpv2.CredentialsSecretPortKey]) + username := string(creds[xpv2.CredentialsSecretUserKey]) + if endpoint == "" || port == "" || username == "" { + return nil, errors.New(errMissingConnDetails) + } + if region == "" { + return nil, errors.New(errNoRegion) } - region := ResolveRegion(specRegion, creds, cfg.Region) - return InjectToken(ctx, creds, region, cfg.Credentials, auth.BuildAuthToken) + address := net.JoinHostPort(endpoint, port) + return func(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + token, err := build(ctx, address, region, username, awsCreds) + if err != nil { + return "", errors.Wrap(err, errBuildToken) + } + return token, nil + }, nil } diff --git a/pkg/clients/awsiam/awsiam_test.go b/pkg/clients/awsiam/awsiam_test.go index 23285ff9..dd75ee58 100644 --- a/pkg/clients/awsiam/awsiam_test.go +++ b/pkg/clients/awsiam/awsiam_test.go @@ -18,17 +18,146 @@ package awsiam import ( "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/rds/auth" - "github.com/google/go-cmp/cmp" - "github.com/pkg/errors" - xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" - "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" ) +func TestAWSConfigCache(t *testing.T) { + var calls atomic.Int32 + want := errors.New("temporary config failure") + cache := awsConfigCache{load: func(context.Context) (aws.Config, error) { + if calls.Add(1) == 1 { + return aws.Config{}, want + } + return aws.Config{Region: "eu-west-1"}, nil + }} + if _, err := cache.get(context.Background()); !errors.Is(err, want) { + t.Fatalf("expected first load failure, got %v", err) + } + var workers sync.WaitGroup + for range 20 { + workers.Go(func() { + cfg, err := cache.get(context.Background()) + if err != nil || cfg.Region != "eu-west-1" { + t.Errorf("cached config: region=%q error=%v", cfg.Region, err) + } + }) + } + workers.Wait() + if got := calls.Load(); got != 2 { + t.Fatalf("expected one failure then one shared successful load, got %d", got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cache.get(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled config request: %v", err) + } +} + +type configWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *configWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +func TestAWSConfigCacheCanceledWaiter(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + cache := awsConfigCache{load: func(context.Context) (aws.Config, error) { + close(started) + <-release + return aws.Config{Region: "eu-west-1"}, nil + }} + loaded := make(chan error, 1) + go func() { _, err := cache.get(context.Background()); loaded <- err }() + <-started + t.Cleanup(func() { close(release); <-loaded }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + waiter := &configWaitContext{Context: ctx, waiting: make(chan struct{})} + result := make(chan error, 1) + go func() { _, err := cache.get(waiter); result <- err }() + select { + case <-waiter.waiting: + case <-time.After(5 * time.Second): + t.Fatal("second caller did not wait on the active config load") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("waiting caller returned %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled caller remained blocked on another caller's config load") + } +} + +func TestNewTokenProviderWithSDKEnvironment(t *testing.T) { + // Keep the process-wide cache and the developer's AWS configuration isolated. + if os.Getenv("PROVIDER_SQL_AWS_CONFIG_TEST") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestNewTokenProviderWithSDKEnvironment$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_AWS_CONFIG_TEST=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK config subprocess: %v\n%s", err, out) + } + return + } + for name, value := range map[string]string{ + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + "AWS_ACCESS_KEY_ID": "SYNTHETICACCESS", "AWS_SECRET_ACCESS_KEY": "synthetic-secret", + "AWS_SESSION_TOKEN": "", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "", + "AWS_EC2_METADATA_DISABLED": "true", "AWS_WEB_IDENTITY_TOKEN_FILE": "", "AWS_ROLE_ARN": "", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "", "AWS_CONTAINER_CREDENTIALS_FULL_URI": "", + } { + t.Setenv(name, value) + } + creds := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + } + for _, region := range []string{"", "eu-central-1"} { + provider, err := NewTokenProvider(context.Background(), ®ion, creds) + if err != nil { + t.Fatal(err) + } + token, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse("https://" + token) + if err != nil { + t.Fatal(err) + } + wantRegion := region + if wantRegion == "" { + wantRegion = "eu-west-1" + } + if parsed.Query().Get("DBUser") != "user" || !strings.Contains(parsed.Query().Get("X-Amz-Credential"), "/"+wantRegion+"/rds-db/") { + t.Fatal("SDK token did not use the configured user and region") + } + } +} + // builderCall records what a stub TokenBuilder was invoked with, so a test can // assert on it afterwards. type builderCall struct { @@ -98,85 +227,116 @@ func TestResolveRegion(t *testing.T) { } } -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{ - xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), - xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), - xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), +func TestTokenProvider(t *testing.T) { + creds := map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), xpv2.CredentialsSecretPortKey: []byte("5432"), xpv2.CredentialsSecretUserKey: []byte("admin")} + now := time.Unix(0, 0) + calls := 0 + build := func(ctx context.Context, host, region, user string, _ aws.CredentialsProvider, _ ...func(*auth.BuildAuthTokenOptions)) (string, error) { + if host != "db.example.com:5432" || region != "eu-west-1" || user != "admin" { + t.Error("incorrect signing inputs") } + calls++ + return fmt.Sprintf("token-%d", now.Unix()), nil } + provider, err := tokenProvider(creds, "eu-west-1", nil, build) + if err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatal("must not sign a token before a physical connection needs it") + } + first, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + now = now.Add(20 * time.Minute) + // The closure captures immutable connection data, not the mutable Secret map. + creds[xpv2.CredentialsSecretEndpointKey][0] = 'X' + next, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if first == next || calls != 2 { + t.Fatal("later connection reused the initial token") + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be written into the Secret map") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := provider(ctx); err != context.Canceled { + t.Fatalf("expected cancellation, got %v", err) + } + if calls != 2 { + t.Error("cancellation must prevent signing") + } +} - 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) - } - - // The token must be written into the password slot. - if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "FAKE_TOKEN" { - t.Errorf("password: want %q, got %q", "FAKE_TOKEN", got) - } - // The builder must have received endpoint as host:port, plus region and user. - 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("MissingUsername", func(t *testing.T) { - creds := fullCreds() - delete(creds, xpv1.ResourceCredentialsSecretUserKey) - 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 { - 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 { - t.Error("builder must not be called when region is empty") - } - }) - - t.Run("BuilderError", func(t *testing.T) { - creds := fullCreds() - rec := &builderCall{} +func TestTokenProviderValidationAndErrors(t *testing.T) { + for _, missing := range []string{"endpoint", "port", "username", "region"} { + t.Run(missing, func(t *testing.T) { + creds := map[string][]byte{"endpoint": []byte("db"), "port": []byte("3306"), "username": []byte("user")} + region := "eu-west-1" + if missing == "region" { + region = "" + } else { + delete(creds, missing) + } + if _, err := tokenProvider(creds, region, nil, nil); err == nil { + t.Error("expected validation failure") + } + }) + } + rec := &builderCall{} + provider, err := tokenProvider(map[string][]byte{"endpoint": []byte("2001:db8::1"), "port": []byte("5432"), "username": []byte("user")}, "eu-west-1", nil, recordingBuilder("", fmt.Errorf("signing failed"), rec)) + if err != nil { + t.Fatal(err) + } + if _, err := provider(context.Background()); err == nil { + t.Error("signing error was swallowed") + } + if rec.endpoint != "[2001:db8::1]:5432" { + t.Fatalf("invalid IPv6 host/port: %q", rec.endpoint) + } +} - 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) - } - // The password must remain unset when token generation fails. - if _, ok := creds[xpv1.ResourceCredentialsSecretPasswordKey]; ok { - t.Error("password must not be set when token generation fails") +func TestTokenProviderRefreshesAWSCredentials(t *testing.T) { + calls := 0 + fail := false + boom := errors.New("AWS credentials unavailable") + cache := aws.NewCredentialsCache(aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { + if fail { + return aws.Credentials{}, boom } - }) + calls++ + return aws.Credentials{AccessKeyID: fmt.Sprintf("TESTKEY%d", calls), SecretAccessKey: "test-secret", SessionToken: "test-session", CanExpire: true, Expires: time.Now().Add(time.Hour)}, nil + })) + provider, err := tokenProvider(map[string][]byte{"endpoint": []byte("db.example.com"), "port": []byte("3306"), "username": []byte("user")}, "eu-west-1", cache, auth.BuildAuthToken) + if err != nil { + t.Fatal(err) + } + first, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(first, "TESTKEY1") { + t.Fatal("token did not use initial AWS credentials") + } + cache.Invalidate() + second, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(second, "TESTKEY2") { + t.Fatal("token reused captured AWS credentials") + } + fail = true + cache.Invalidate() + if _, err := provider(context.Background()); !errors.Is(err, boom) { + t.Fatalf("credential error was lost: %v", err) + } + fail = false + if _, err := provider(context.Background()); err != nil { + t.Fatalf("provider failed to recover: %v", err) + } } diff --git a/pkg/clients/awsiam/config_test.go b/pkg/clients/awsiam/config_test.go new file mode 100644 index 00000000..1b921055 --- /dev/null +++ b/pkg/clients/awsiam/config_test.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsiam + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +func TestNewTokenProviderSharesRenewableSDKCredentials(t *testing.T) { + if os.Getenv("PROVIDER_SQL_ENDPOINT_CREDENTIALS_TEST") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestNewTokenProviderSharesRenewableSDKCredentials$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_ENDPOINT_CREDENTIALS_TEST=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK credential subprocess: %v\n%s", err, out) + } + return + } + var requests, version atomic.Int32 + version.Store(1) + var authorization atomic.Value + authorization.Store("pod-token-one") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Authorization") != authorization.Load().(string) { + http.Error(w, "wrong workload token", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "AccessKeyId": fmt.Sprintf("KEY%d", version.Load()), "SecretAccessKey": "synthetic-secret", + "Token": "synthetic-session", "Expiration": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "pod-token") + if err := os.WriteFile(tokenFile, []byte("pod-token-one"), 0600); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "", + "AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_KEY": "", "AWS_SESSION_TOKEN": "", + "AWS_EC2_METADATA_DISABLED": "true", "AWS_WEB_IDENTITY_TOKEN_FILE": "", "AWS_ROLE_ARN": "", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "", "AWS_CONTAINER_CREDENTIALS_FULL_URI": server.URL, + "AWS_CONTAINER_AUTHORIZATION_TOKEN": "", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE": tokenFile, + } { + t.Setenv(name, value) + } + details := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), xpv2.CredentialsSecretUserKey: []byte("user"), + } + initialCtx, cancel := context.WithCancel(context.Background()) + first, err := NewTokenProvider(initialCtx, nil, details) + cancel() + if err != nil { + t.Fatal(err) + } + assertToken := func(provider func(context.Context) (string, error), key string) { + t.Helper() + token, err := provider(context.Background()) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse("https://" + token) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(parsed.Query().Get("X-Amz-Credential"), key+"/") { + t.Fatalf("token did not use renewed key %s", key) + } + } + assertToken(first, "KEY1") // The initializer's canceled context must not survive in the SDK provider. + second, err := NewTokenProvider(context.Background(), nil, details) + if err != nil { + t.Fatal(err) + } + assertToken(second, "KEY1") + if got := requests.Load(); got != 1 { + t.Fatalf("two reconciles fetched AWS credentials %d times", got) + } + + if err := os.WriteFile(tokenFile, []byte("pod-token-two"), 0600); err != nil { + t.Fatal(err) + } + authorization.Store("pod-token-two") + version.Store(2) + cfg, err := defaultConfigCache.get(context.Background()) + if err != nil { + t.Fatal(err) + } + cache, ok := cfg.Credentials.(*aws.CredentialsCache) + if !ok { + t.Fatalf("expected SDK credentials cache, got %T", cfg.Credentials) + } + cache.Invalidate() + assertToken(second, "KEY2") + assertToken(first, "KEY2") + if got := requests.Load(); got != 2 { + t.Fatalf("expected one initial retrieval and one renewal, got %d", got) + } +} diff --git a/pkg/clients/mysql/custom_tls_test.go b/pkg/clients/mysql/custom_tls_test.go new file mode 100644 index 00000000..12a30d31 --- /dev/null +++ b/pkg/clients/mysql/custom_tls_test.go @@ -0,0 +1,165 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mysql_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + cluster "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" + clusterTLS "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + modernTLS "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + driverMySQL "github.com/go-sql-driver/mysql" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func testCA(t *testing.T, name string) ([]byte, []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) +} + +func TestCustomTLSCAOnlyAndClientPair(t *testing.T) { + ca1, key1 := testCA(t, "database-one") + ca2, _ := testCA(t, "database-two") + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "one", Namespace: "certs"}, Data: map[string][]byte{"ca": ca1, "cert": ca1, "key": key1, "bad": []byte("invalid PEM")}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "two", Namespace: "certs"}, Data: map[string][]byte{"ca": ca2}}, + ).Build() + ref := func(name, key string) xpv2.SecretKeySelector { + return xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: name, Namespace: "certs"}, Key: key} + } + type loadFunc func(context.Context, client.Client, string, *string, *cluster.TLSConfig) (*string, error) + loaders := map[string]loadFunc{ + "cluster": clusterTLS.LoadConfig, + "namespaced": func(ctx context.Context, k client.Client, name string, mode *string, cfg *cluster.TLSConfig) (*string, error) { + var next *modern.TLSConfig + if cfg != nil { + next = &modern.TLSConfig{CACert: modern.TLSSecret{SecretRef: cfg.CACert.SecretRef}, ClientCert: modern.TLSSecret{SecretRef: cfg.ClientCert.SecretRef}, ClientKey: modern.TLSSecret{SecretRef: cfg.ClientKey.SecretRef}, InsecureSkipVerify: cfg.InsecureSkipVerify} + } + return modernTLS.LoadConfig(ctx, k, name, mode, next) + }, + } + for scope, load := range loaders { + t.Run(scope, func(t *testing.T) { + mode := "custom" + caOnly := &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "ca")}} + name, err := load(context.Background(), kube, "ProviderConfig/ns-one/same", &mode, caOnly) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*name) + // Loading another database must not replace the first database's CA pool. + other, err := load(context.Background(), kube, "ProviderConfig/ns-two/same", &mode, &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("two", "ca")}}) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*other) + if *name == *other { + t.Fatal("distinct ProviderConfigs share a TLS registration") + } + for _, tc := range []struct { + name string + ca []byte + }{{*name, ca1}, {*other, ca2}} { + cfg, err := driverMySQL.ParseDSN("user:token@tcp(db.example.com:3306)/?tls=" + tc.name) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(tc.ca) + if !cfg.TLS.RootCAs.Equal(pool) { + t.Fatal("incorrect per-database CA pool") + } + if cfg.TLS.InsecureSkipVerify || len(cfg.TLS.Certificates) != 0 || cfg.TLS.ServerName != "db.example.com" { + t.Fatal("CA-only configuration must verify hostname without a client certificate") + } + } + pair := *caOnly + pair.ClientCert.SecretRef = ref("one", "cert") + pair.ClientKey.SecretRef = ref("one", "key") + name, err = load(context.Background(), kube, "mutual-tls", &mode, &pair) + if err != nil { + t.Fatal(err) + } + defer driverMySQL.DeregisterTLSConfig(*name) + cfg, err := driverMySQL.ParseDSN("user:password@tcp(db.example.com:3306)/?tls=" + *name) + if err != nil { + t.Fatal(err) + } + if len(cfg.TLS.Certificates) != 1 { + t.Fatal("existing mutual TLS configuration lost its client certificate") + } + for _, tc := range []struct { + name string + cfg *cluster.TLSConfig + want string + }{ + {"missing config", nil, "caCert"}, + {"missing CA", &cluster.TLSConfig{}, "caCert"}, + {"cert without key", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert}, "together"}, + {"key without cert", &cluster.TLSConfig{CACert: caOnly.CACert, ClientKey: pair.ClientKey}, "together"}, + {"partial reference", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert, ClientKey: cluster.TLSSecret{SecretRef: ref("one", "")}}, "complete"}, + {"missing secret", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("missing", "ca")}}, "cannot get CA"}, + {"missing key", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "missing")}}, "cannot get CA"}, + {"invalid CA", &cluster.TLSConfig{CACert: cluster.TLSSecret{SecretRef: ref("one", "bad")}}, "cannot append"}, + {"invalid client key", &cluster.TLSConfig{CACert: caOnly.CACert, ClientCert: pair.ClientCert, ClientKey: cluster.TLSSecret{SecretRef: ref("one", "bad")}}, "cannot make client"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := load(context.Background(), kube, tc.name, &mode, tc.cfg); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } + plain := "true" + if _, err := load(context.Background(), kube, "invalid-mode", &plain, caOnly); err == nil { + t.Error("tlsConfig must still require custom mode") + } + }) + } +} diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index 0655cb84..75bff69d 100644 --- a/pkg/clients/mysql/mysql.go +++ b/pkg/clients/mysql/mysql.go @@ -3,10 +3,13 @@ package mysql import ( "context" "database/sql" + "database/sql/driver" "fmt" "strconv" "strings" + driverMySQL "github.com/go-sql-driver/mysql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/pkg/errors" @@ -16,13 +19,15 @@ import ( const ( errNotSupported = "%s not supported by mysql client" + tlsVerify = "true" ) type mySQLDB struct { - dsn string - endpoint string - port string - tls string + dsn string + endpoint string + port string + tls string + passwordProvider xsql.PasswordProvider } // New returns a new MySQL database client. When cleartext is true the DSN sets @@ -47,16 +52,38 @@ func New(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsq } } -// EnsureTLS returns a TLS mode that guarantees an encrypted connection, for use -// with AWS IAM authentication. If mode is unset or non-encrypting -// ("preferred"/"false") it returns "skip-verify" (encrypted but unverified); -// otherwise it returns mode unchanged so an operator-configured verifying mode -// ("true" or a registered custom configuration) is preserved. Custom TLS -// configuration loading and certificate requirements are handled separately. +// NewWithPasswordProvider configures credentials generated for each physical connection. +func NewWithPasswordProvider(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB { + db := New(creds, tls, binlog, cleartext).(mySQLDB) + db.passwordProvider = password + return db +} + +func (c mySQLDB) open() (*sql.DB, error) { + if c.passwordProvider == nil { + return sql.Open("mysql", c.dsn) + } + cfg, err := driverMySQL.ParseDSN(c.dsn) + if err != nil { + return nil, err + } + return sql.OpenDB(&xsql.PasswordConnector{ + Password: c.passwordProvider, + SQLDriver: &driverMySQL.MySQLDriver{}, + NewConnector: func(password string) (driver.Connector, error) { + next := cfg.Clone() + next.Passwd = password + return driverMySQL.NewConnector(next) + }, + }), nil +} + +// EnsureTLS selects verified TLS for IAM when mode is unset or permits plaintext. +// Explicit skip-verify and registered custom configurations remain operator choices. func EnsureTLS(mode *string) *string { - if mode == nil || *mode == "false" || *mode == "preferred" { - skipVerify := "skip-verify" - return &skipVerify + if mode == nil || *mode == "" || *mode == "false" || *mode == "preferred" { + verified := tlsVerify + return &verified } return mode } @@ -64,9 +91,7 @@ func EnsureTLS(mode *string) *string { // DSN returns the DSN URL. When cleartext is true, allowCleartextPasswords=true // is appended (required for AWS RDS IAM authentication). func DSN(username, password, endpoint, port, tls string, binlog *bool, cleartext bool) string { - // 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 + // MySQL DSN passwords are passed verbatim. URL-encoding would change IAM tokens. dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", username, password, endpoint, port, tls) if binlog != nil { dsn += "&sql_log_bin=" + strconv.FormatBool(*binlog) @@ -84,7 +109,7 @@ func (c mySQLDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Exec the supplied query. func (c mySQLDB) Exec(ctx context.Context, q xsql.Query) error { - d, err := sql.Open("mysql", c.dsn) + d, err := c.open() if err != nil { return err } @@ -96,7 +121,7 @@ func (c mySQLDB) Exec(ctx context.Context, q xsql.Query) error { // Query the supplied query. func (c mySQLDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { - d, err := sql.Open("mysql", c.dsn) + d, err := c.open() if err != nil { return nil, err } @@ -108,7 +133,7 @@ func (c mySQLDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { // Scan the results of the supplied query into the supplied destination. func (c mySQLDB) Scan(ctx context.Context, q xsql.Query, dest ...interface{}) error { - db, err := sql.Open("mysql", c.dsn) + db, err := c.open() if err != nil { return err } diff --git a/pkg/clients/mysql/mysql_test.go b/pkg/clients/mysql/mysql_test.go index 18548eab..8fa02c23 100644 --- a/pkg/clients/mysql/mysql_test.go +++ b/pkg/clients/mysql/mysql_test.go @@ -4,6 +4,8 @@ import ( "fmt" "strconv" "testing" + + driverMySQL "github.com/go-sql-driver/mysql" ) func TestDSNURLEscaping(t *testing.T) { @@ -54,3 +56,44 @@ func TestDSNCleartext(t *testing.T) { t.Errorf("non-cleartext DSN: want %q, got %q", base, got) } } + +func TestEnsureTLS(t *testing.T) { + for _, mode := range []string{"unset", "", "false", "preferred", "true", "skip-verify", "custom-example"} { + t.Run(mode, func(t *testing.T) { + var input *string + if mode != "unset" { + input = &mode + } + want := mode + if input == nil || mode == "" || mode == "false" || mode == "preferred" { + want = "true" + } + got := EnsureTLS(input) + if got == nil || *got != want { + t.Fatalf("want %q, got %v", want, got) + } + if want == mode && got != input { + t.Error("explicit setting should be preserved") + } + if want == "true" { + cfg, err := driverMySQL.ParseDSN(DSN("user", "token", "db.example.com", "3306", *got, nil, true)) + if err != nil { + t.Fatal(err) + } + if cfg.TLS == nil || cfg.TLS.InsecureSkipVerify || cfg.AllowFallbackToPlaintext { + t.Fatal("default IAM mode must verify TLS without plaintext fallback") + } + if cfg.TLS.ServerName != "db.example.com" { + t.Errorf("missing hostname: %q", cfg.TLS.ServerName) + } + } + }) + } +} + +func TestPasswordAuthenticationTLSDefaultUnchanged(t *testing.T) { + db := New(map[string][]byte{}, nil, nil, false).(mySQLDB) + if db.tls != "preferred" { + t.Fatalf("non-IAM default changed to %q", db.tls) + } +} diff --git a/pkg/clients/mysql/tls_test.go b/pkg/clients/mysql/tls_test.go new file mode 100644 index 00000000..83ffaec1 --- /dev/null +++ b/pkg/clients/mysql/tls_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mysql + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// Exercise the real MySQL driver and its TLS negotiation, without AWS or a +// database process. The server returns a recognizable error after receiving +// the authentication packet so the test proves TLS and password injection ran. +func TestIAMTLSNegotiation(t *testing.T) { + if os.Getenv("PROVIDER_SQL_MYSQL_TLS_CHILD") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestIAMTLSNegotiation$", "-test.v", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "PROVIDER_SQL_MYSQL_TLS_CHILD=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("TLS subprocess: %v\n%s", err, out) + } + return + } + dir := t.TempDir() + t.Setenv("SSL_CERT_FILE", filepath.Join(dir, "ca.pem")) + t.Setenv("SSL_CERT_DIR", dir) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ca := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("SSL_CERT_FILE"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, mode string + trusted, matching bool + want string + }{ + {"omitted verifies", "unset", true, true, "TLS accepted"}, + {"omitted rejects untrusted", "unset", false, true, "unknown authority"}, + {"empty rejects untrusted", "", false, true, "unknown authority"}, + {"false rejects untrusted", "false", false, true, "unknown authority"}, + {"preferred rejects untrusted", "preferred", false, true, "unknown authority"}, + {"default rejects hostname mismatch", "unset", true, false, "not 127.0.0.1"}, + {"explicit unverified opt-in", "skip-verify", false, false, "TLS accepted"}, + } { + t.Run(tc.name, func(t *testing.T) { + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ip := "127.0.0.1" + if !tc.matching { + ip = "127.0.0.2" + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP(ip)}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature} + parent, signer := ca, key + if !tc.trusted { + parent, signer = leaf, leafKey + } + der, err := x509.CreateCertificate(rand.Reader, leaf, parent, &leafKey.PublicKey, signer) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan bool, 1) + go func() { + accepted := false + defer func() { done <- accepted }() + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + const capabilities uint32 = 1 | 512 | 2048 | 8192 | 32768 | 524288 + greeting := append([]byte{10}, []byte("8.4-test\x00")...) + greeting = append(greeting, 1, 0, 0, 0) + greeting = append(greeting, []byte("12345678\x00")...) + greeting = append(greeting, byte(capabilities&255), byte((capabilities>>8)&255), 45, 2, 0, byte((capabilities>>16)&255), byte((capabilities>>24)&255), 21) + greeting = append(greeting, make([]byte, 10)...) + greeting = append(greeting, []byte("123456789012\x00mysql_clear_password\x00")...) + if writeMySQLTestPacket(conn, 0, greeting) != nil { + return + } + request, err := readMySQLTestPacket(conn) + if err != nil || len(request) != 32 { + return + } + if binary.LittleEndian.Uint32(request)&2048 == 0 { + return + } + secure := tls.Server(conn, &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: leafKey}}}) + if secure.Handshake() != nil { + return + } + auth, err := readMySQLTestPacket(secure) + if err != nil { + return + } + accepted = strings.Contains(string(auth), "fresh-token") + _ = writeMySQLTestPacket(secure, 3, append([]byte{255, 0x15, 0x04, '#', '2', '8', '0', '0', '0'}, []byte("TLS accepted")...)) + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + var mode *string + if tc.mode != "unset" { + mode = &tc.mode + } + signed := 0 + db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("iam-user")}, EnsureTLS(mode), nil, true, func(context.Context) (string, error) { signed++; return "fresh-token", nil }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var value int + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, &value) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("want %q, got %v", tc.want, err) + } + _ = listener.Close() + accepted := <-done + if tc.want == "TLS accepted" && !accepted { + t.Error("fresh password did not reach authentication packet") + } + if signed != 1 { + t.Errorf("expected one signing call, got %d", signed) + } + }) + } +} + +func writeMySQLTestPacket(w io.Writer, seq byte, body []byte) error { + header := []byte{byte(len(body)), byte(len(body) >> 8), byte(len(body) >> 16), seq} + _, err := w.Write(append(header, body...)) + return err +} +func readMySQLTestPacket(r io.Reader) ([]byte, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, err + } + n := int(header[0]) | int(header[1])<<8 | int(header[2])<<16 + body := make([]byte, n) + _, err := io.ReadFull(r, body) + return body, err +} diff --git a/pkg/clients/postgresql/postgresql.go b/pkg/clients/postgresql/postgresql.go index db3d1b38..b9f78e89 100644 --- a/pkg/clients/postgresql/postgresql.go +++ b/pkg/clients/postgresql/postgresql.go @@ -3,7 +3,9 @@ package postgresql import ( "context" "database/sql" + "database/sql/driver" "errors" + "net" "net/url" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" @@ -21,10 +23,11 @@ const ( ) type postgresDB struct { - dsn string - endpoint string - port string - sslmode string + dsn string + endpoint string + port string + sslmode string + passwordProvider xsql.PasswordProvider } // New returns a new PostgreSQL database client. The default database name is @@ -46,11 +49,54 @@ func New(creds map[string][]byte, database, sslmode string) xsql.DB { } } +// NewWithPasswordProvider configures credentials generated for each physical connection. +func NewWithPasswordProvider(creds map[string][]byte, database, sslmode string, password xsql.PasswordProvider) xsql.DB { + db := New(creds, database, sslmode).(postgresDB) + db.passwordProvider = password + if password != nil { + // Validate connection settings without including any credential in parser errors. + db.dsn = DSN(string(creds[xpv2.CredentialsSecretUserKey]), "", db.endpoint, db.port, database, sslmode) + } + return db +} + +func (c postgresDB) open() (*sql.DB, error) { + cfg, err := pq.NewConfig(c.dsn) + if err != nil { + return nil, withoutConnectionURL(err) + } + if c.passwordProvider == nil { + connector, err := pq.NewConnectorConfig(cfg) + if err != nil { + return nil, err + } + return sql.OpenDB(connector), nil + } + return sql.OpenDB(&xsql.PasswordConnector{ + Password: c.passwordProvider, + SQLDriver: &pq.Driver{}, + NewConnector: func(password string) (driver.Connector, error) { + next := cfg.Clone() + next.Password = password + return pq.NewConnectorConfig(next) + }, + }), nil +} + +// URL parser errors quote the complete DSN, including its password. +func withoutConnectionURL(err error) error { + var parseErr *url.Error + if errors.As(err, &parseErr) { + return parseErr.Err + } + return err +} + // EnsureTLS requires encryption without weakening configured verification. // Kubernetes defaults an omitted sslMode to verify-full; empty handles callers // that construct ProviderConfigs without API-server defaulting. func EnsureTLS(mode string) string { - if mode == "" || mode == "disable" { + if mode == "" || mode == "disable" || mode == "allow" || mode == "prefer" { return "require" } return mode @@ -58,26 +104,27 @@ func EnsureTLS(mode string) string { // DSN returns the DSN URL func DSN(username, password, endpoint, port, database, sslmode string) string { - // Use net/url UserPassword to encode the username and password - // This will ensure that any special characters in the username or password - // are percent-encoded for use in the user info portion of the DSN URL - userInfo := url.UserPassword(username, password) - return "postgres://" + - userInfo.String() + "@" + - endpoint + ":" + - port + "/" + - database + - "?sslmode=" + sslmode + // Keep database names and credentials separate from connection options. + connection := url.URL{ + Scheme: "postgres", + User: url.UserPassword(username, password), + Host: net.JoinHostPort(endpoint, port), + Path: "/" + database, + RawQuery: url.Values{"sslmode": {sslmode}}.Encode(), + } + return connection.String() } // ExecTx executes an array of queries, committing if all are successful and // rolling back immediately on failure. func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return err } + defer d.Close() //nolint:errcheck + tx, err := d.BeginTx(ctx, nil) if err != nil { return err @@ -86,7 +133,6 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Rollback or Commit based on error state. Defer close in defer to make // sure the connection is always closed. defer func() { - defer d.Close() //nolint:errcheck // We always rollback, it's a no-op if the tx was already committed. defer tx.Rollback() //nolint:errcheck @@ -96,7 +142,7 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { }() for _, q := range ql { - if _, err = tx.Exec(q.String, q.Parameters...); err != nil { + if _, err = tx.ExecContext(ctx, q.String, q.Parameters...); err != nil { return err } } @@ -105,7 +151,7 @@ func (c postgresDB) ExecTx(ctx context.Context, ql []xsql.Query) error { // Exec the supplied query. func (c postgresDB) Exec(ctx context.Context, q xsql.Query) error { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return err } @@ -117,7 +163,7 @@ func (c postgresDB) Exec(ctx context.Context, q xsql.Query) error { // Query the supplied query. func (c postgresDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) { - d, err := sql.Open("postgres", c.dsn) + d, err := c.open() if err != nil { return nil, err } @@ -129,7 +175,7 @@ func (c postgresDB) Query(ctx context.Context, q xsql.Query) (*sql.Rows, error) // Scan the results of the supplied query into the supplied destination. func (c postgresDB) Scan(ctx context.Context, q xsql.Query, dest ...interface{}) error { - db, err := sql.Open("postgres", c.dsn) + db, err := c.open() if err != nil { return err } @@ -151,7 +197,7 @@ func (c postgresDB) GetConnectionDetails(username, password string) managed.Conn // GetServerVersion returns the PostgreSQL server version as an integer // For example, PostgreSQL 16.2 would return 160200. func (c postgresDB) GetServerVersion(ctx context.Context) (int, error) { - db, err := sql.Open("postgres", c.dsn) + db, err := c.open() if err != nil { return 0, err } diff --git a/pkg/clients/postgresql/postgresql_test.go b/pkg/clients/postgresql/postgresql_test.go index 0746ffbd..77723587 100644 --- a/pkg/clients/postgresql/postgresql_test.go +++ b/pkg/clients/postgresql/postgresql_test.go @@ -1,7 +1,14 @@ package postgresql import ( + "context" + "strings" "testing" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" ) func TestDSNURLEscaping(t *testing.T) { @@ -18,9 +25,74 @@ func TestDSNURLEscaping(t *testing.T) { } } +func TestPasswordProviderRejectsMalformedConfigBeforeSigning(t *testing.T) { + const token = "SYNTHETIC-IAM-TOKEN" + for _, tc := range []struct { + name, endpoint, port, database string + }{ + {"host control character", "db.example.com\n", "5432", "postgres"}, + {"invalid port", "db.example.com", "invalid", "postgres"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + db := NewWithPasswordProvider(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte(tc.endpoint), + xpv2.CredentialsSecretPortKey: []byte(tc.port), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte("IGNORED-STATIC-PASSWORD"), + }, tc.database, "verify-full", func(context.Context) (string, error) { + calls++ + return token, nil + }) + err := db.Scan(context.Background(), xsql.Query{String: "SELECT 1"}, new(int)) + if err == nil { + t.Fatal("malformed connection settings must fail") + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "IGNORED-STATIC-PASSWORD") { + t.Fatalf("connection error exposed a credential: %v", err) + } + if calls != 0 { + t.Fatalf("signed %d tokens before rejecting malformed settings", calls) + } + }) + } +} + +func TestDSNDatabaseCannotOverrideConnectionSettings(t *testing.T) { + for _, database := range []string{ + "sales%zz", "sales#archive", "sales\n", "sales/2026", + "postgres?hostaddr=203.0.113.10&sslmode=disable&x=", + } { + t.Run(database, func(t *testing.T) { + cfg, err := pq.NewConfig(DSN("user", "password", "db.example.com", "5432", database, "verify-full")) + if err != nil { + t.Fatal(err) + } + if cfg.Host != "db.example.com" || cfg.Hostaddr.IsValid() || cfg.Database != database || cfg.SSLMode != "verify-full" { + t.Fatalf("database name changed connection settings: host=%q hostaddr=%v database=%q sslmode=%q", cfg.Host, cfg.Hostaddr, cfg.Database, cfg.SSLMode) + } + }) + } +} + +func TestStaticPasswordNotInConfigError(t *testing.T) { + const password = "SYNTHETIC-STATIC-PASSWORD" + db := New(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.com\n"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte(password), + }, "postgres", "verify-full") + err := db.Scan(context.Background(), xsql.Query{String: "SELECT 1"}, new(int)) + if err == nil || strings.Contains(err.Error(), password) { + t.Fatalf("expected a connection error without the password, got %v", err) + } +} + func TestEnsureTLS(t *testing.T) { for _, tc := range []struct{ mode, want string }{ {"", "require"}, {"disable", "require"}, {"require", "require"}, + {"allow", "require"}, {"prefer", "require"}, {"verify-ca", "verify-ca"}, {"verify-full", "verify-full"}, {"invalid", "invalid"}, } { t.Run(tc.mode, func(t *testing.T) { diff --git a/pkg/clients/postgresql/tls_test.go b/pkg/clients/postgresql/tls_test.go index 0c315847..53528de2 100644 --- a/pkg/clients/postgresql/tls_test.go +++ b/pkg/clients/postgresql/tls_test.go @@ -24,6 +24,54 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" ) +func TestIAMRequiresTLSWhenServerRefusesIt(t *testing.T) { + for _, mode := range []string{"", "disable", "allow", "prefer"} { + t.Run(mode, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan bool, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- false + return + } + defer conn.Close() + _ = listener.Close() // Any plaintext retry must fail too. + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + request := make([]byte, 8) + _, err = io.ReadFull(conn, request) + isSSL := err == nil && binary.BigEndian.Uint32(request[4:]) == 80877103 + if isSSL { + _, _ = conn.Write([]byte("N")) + } + done <- isSSL + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + db := NewWithPasswordProvider(map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte(host), + xpv2.CredentialsSecretPortKey: []byte(port), + xpv2.CredentialsSecretUserKey: []byte("user"), + }, "postgres", EnsureTLS(mode), func(context.Context) (string, error) { return "fresh-token", nil }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = db.Scan(ctx, xsql.Query{String: "SELECT 1"}, new(int)) + _ = listener.Close() + if !<-done { + t.Error("driver did not request TLS before authenticating") + } + if err == nil || !strings.Contains(err.Error(), "SSL is not enabled on the server") { + t.Fatalf("expected refusal of plaintext without fallback, got %v", err) + } + }) + } +} + // Isolate Go's cached system roots so this test exercises the documented // SSL_CERT_FILE mount mechanism without affecting other tests in the package. func TestTLSSystemRoots(t *testing.T) { @@ -59,13 +107,21 @@ func TestTLSSystemRoots(t *testing.T) { name, mode string trusted, matching bool want string + rootFile bool }{ - {"trusted matching", "verify-full", true, true, "TLS accepted"}, - {"untrusted", "verify-full", false, true, "unknown authority"}, - {"wrong hostname", "verify-full", true, false, "not 127.0.0.1"}, - {"CA only permits hostname mismatch", "verify-ca", true, false, "TLS accepted"}, + {"trusted matching", "verify-full", true, true, "TLS accepted", false}, + {"untrusted", "verify-full", false, true, "unknown authority", false}, + {"wrong hostname", "verify-full", true, false, "not 127.0.0.1", false}, + {"CA only permits hostname mismatch", "verify-ca", true, false, "TLS accepted", false}, + {"require ignores system roots", "require", false, false, "TLS accepted", false}, + {"verify-ca rejects untrusted", "verify-ca", false, true, "unknown authority", false}, + {"require with root file verifies CA", "require", false, true, "unknown authority", true}, + {"require with root file skips hostname", "require", true, false, "TLS accepted", true}, } { t.Run(tc.name, func(t *testing.T) { + if tc.rootFile { + t.Setenv("PGSSLROOTCERT", os.Getenv("SSL_CERT_FILE")) + } key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) @@ -89,6 +145,8 @@ func TestTLSSystemRoots(t *testing.T) { } defer listener.Close() done := make(chan struct{}) + passwords := make(chan string, 1) + const token = "db.example.com:5432/?Action=connect&DBUser=test&X-Amz-Signature=a%2Fb+c" go func() { defer close(done) conn, err := listener.Accept() @@ -124,6 +182,23 @@ func TestTLSSystemRoots(t *testing.T) { if _, err = io.CopyN(io.Discard, secure, int64(size-4)); err != nil { return } + // RDS PostgreSQL requests the IAM token as a cleartext password inside TLS. + if _, err = secure.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 3}); err != nil { + return + } + header := make([]byte, 5) + if _, err = io.ReadFull(secure, header); err != nil || header[0] != 'p' { + return + } + size = binary.BigEndian.Uint32(header[1:]) + if size < 5 || size > 65536 { + return + } + password := make([]byte, size-4) + if _, err = io.ReadFull(secure, password); err != nil { + return + } + passwords <- string(password) // A PostgreSQL startup error proves TLS verification completed. No real // PostgreSQL server or database credentials are needed. body := []byte("SFATAL\x00C28000\x00MTLS accepted\x00\x00") @@ -136,7 +211,8 @@ func TestTLSSystemRoots(t *testing.T) { if err != nil { t.Fatal(err) } - db := New(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", EnsureTLS(tc.mode)) + signed := 0 + db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", EnsureTLS(tc.mode), func(context.Context) (string, error) { signed++; return token, nil }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var value int @@ -144,8 +220,23 @@ func TestTLSSystemRoots(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want error containing %q, got %v", tc.want, err) } + if signed != 1 { + t.Errorf("expected a freshly generated password, got %d signing calls", signed) + } _ = listener.Close() <-done + if tc.want == "TLS accepted" { + select { + case got := <-passwords: + if got != token+"\x00" { + t.Errorf("driver altered the token: got %q", got) + } + default: + t.Error("server did not receive the generated token") + } + } else if len(passwords) != 0 { + t.Error("driver sent the token before verifying the server") + } }) } } diff --git a/pkg/clients/xsql/password.go b/pkg/clients/xsql/password.go new file mode 100644 index 00000000..589a815f --- /dev/null +++ b/pkg/clients/xsql/password.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xsql + +import ( + "context" + "database/sql/driver" +) + +// PasswordProvider returns credentials for a new physical database connection. +// Implementations must support concurrent calls and respect cancellation. +type PasswordProvider func(context.Context) (string, error) + +// PasswordConnector obtains a fresh password on every physical connection, +// including connections database/sql opens when retrying a bad connection. +type PasswordConnector struct { + Password PasswordProvider + NewConnector func(string) (driver.Connector, error) + SQLDriver driver.Driver +} + +func (c *PasswordConnector) Connect(ctx context.Context) (driver.Conn, error) { + password, err := c.Password(ctx) + if err != nil { + return nil, err + } + connector, err := c.NewConnector(password) + if err != nil { + return nil, err + } + return connector.Connect(ctx) +} + +func (c *PasswordConnector) Driver() driver.Driver { return c.SQLDriver } diff --git a/pkg/clients/xsql/password_test.go b/pkg/clients/xsql/password_test.go new file mode 100644 index 00000000..476ad507 --- /dev/null +++ b/pkg/clients/xsql/password_test.go @@ -0,0 +1,166 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xsql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + "testing" + "time" +) + +type tokenTestDriver struct{} + +func (tokenTestDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("unexpected Driver.Open: bypassed password connector") +} + +type tokenTestConnector struct { + connect func(context.Context) (driver.Conn, error) +} + +func (c tokenTestConnector) Driver() driver.Driver { return tokenTestDriver{} } +func (c tokenTestConnector) Connect(ctx context.Context) (driver.Conn, error) { return c.connect(ctx) } + +type tokenTestConn struct{ bad *atomic.Bool } + +func (tokenTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (tokenTestConn) Close() error { return nil } +func (tokenTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") } +func (c tokenTestConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + if c.bad != nil && c.bad.Swap(false) { + return nil, driver.ErrBadConn + } + return &tokenTestRows{}, nil +} + +type tokenTestRows struct{ read bool } + +func (*tokenTestRows) Columns() []string { return []string{"value"} } +func (*tokenTestRows) Close() error { return nil } +func (r *tokenTestRows) Next(values []driver.Value) error { + if r.read { + return io.EOF + } + values[0] = int64(1) + r.read = true + return nil +} + +func TestPasswordConnectorRefreshesAfterExpiryAndBadConnection(t *testing.T) { + now := time.Unix(0, 0) + var tokens []string + var retry atomic.Bool + connector := &PasswordConnector{ + Password: func(context.Context) (string, error) { return fmt.Sprintf("signed-at-%d", now.Unix()), nil }, + SQLDriver: tokenTestDriver{}, + NewConnector: func(password string) (driver.Connector, error) { + want := fmt.Sprintf("signed-at-%d", now.Unix()) + if password != want { + return nil, errors.New("expired token") + } + tokens = append(tokens, password) + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return tokenTestConn{bad: &retry}, nil }}, nil + }, + } + db := sql.OpenDB(connector) + defer db.Close() + query := func() { + t.Helper() + var value int + if err := db.QueryRowContext(context.Background(), "SELECT 1").Scan(&value); err != nil { + t.Fatal(err) + } + } + query() + now = now.Add(20 * time.Minute) + // Existing authenticated sessions survive token expiry; a broken session + // makes database/sql retry with a new physical connection and a fresh token. + retry.Store(true) + query() + if len(tokens) != 2 || tokens[0] == tokens[1] { + t.Fatalf("expected fresh token after reconnect, got %v", tokens) + } + db.SetMaxIdleConns(0) + now = now.Add(20 * time.Minute) + query() + if len(tokens) != 3 || tokens[1] == tokens[2] { + t.Fatal("later physical connection reused an old token") + } +} + +func TestPasswordConnectorErrorsAndCancellation(t *testing.T) { + boom := errors.New("signing failed") + c := &PasswordConnector{Password: func(context.Context) (string, error) { return "", boom }, SQLDriver: tokenTestDriver{}, NewConnector: func(string) (driver.Connector, error) { + t.Fatal("must not connect after signing error") + return nil, nil + }} + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost signing error: %v", err) + } + c.Password = func(ctx context.Context) (string, error) { return "", ctx.Err() } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Connect(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("lost cancellation: %v", err) + } + c.Password = func(context.Context) (string, error) { return "token", nil } + c.NewConnector = func(string) (driver.Connector, error) { return nil, boom } + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost connector error: %v", err) + } + c.NewConnector = func(string) (driver.Connector, error) { + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return nil, boom }}, nil + } + if _, err := c.Connect(context.Background()); !errors.Is(err, boom) { + t.Fatalf("lost connection error: %v", err) + } +} + +func TestPasswordConnectorConcurrentConnections(t *testing.T) { + var issued atomic.Int64 + var seen sync.Map + c := &PasswordConnector{Password: func(context.Context) (string, error) { return fmt.Sprint(issued.Add(1)), nil }, SQLDriver: tokenTestDriver{}, NewConnector: func(password string) (driver.Connector, error) { + if _, loaded := seen.LoadOrStore(password, true); loaded { + return nil, errors.New("reused token") + } + return tokenTestConnector{connect: func(context.Context) (driver.Conn, error) { return tokenTestConn{}, nil }}, nil + }} + var workers sync.WaitGroup + for range 20 { + workers.Go(func() { + conn, err := c.Connect(context.Background()) + if err != nil { + t.Error(err) + return + } + _ = conn.Close() + }) + } + workers.Wait() + if issued.Load() != 20 { + t.Fatalf("want 20 independently generated tokens, got %d", issued.Load()) + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index f41c6ba5..f496b5b8 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -66,7 +66,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,14 +94,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider 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, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Database] = &connector{} @@ -119,9 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -140,17 +138,18 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) cleartext := false + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } cleartext = true - // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via - // trust-manager or a mounted secret); honour a verifying tls mode if set, - // otherwise fall back to an encrypted (unverified) connection. + // Default IAM connections to verified TLS using the configured trust roots. tlsName = mysql.EnsureTLS(tlsName) } - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/mysql/database/reconciler_test.go b/pkg/controller/cluster/mysql/database/reconciler_test.go index 7c543fc3..2a61e1d3 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -67,17 +67,16 @@ func TestConnect(t *testing.T) { nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } // Stub the AWS IAM token injection so the success case needs no AWS access. - origInject := injectIAM - injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { - creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") - return nil + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil } - defer func() { injectIAM = origInject }() + defer func() { newIAMToken = origInject }() type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -177,27 +176,33 @@ func TestConnect(t *testing.T) { switch o := obj.(type) { case *v1alpha1.ProviderConfig: o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth - o.Spec.Credentials.ConnectionSecretRef = &xpv1.SecretReference{Namespace: "ns", Name: "s"} + o.Spec.Credentials.ConnectionSecretRef = &xpv2.SecretReference{Namespace: "ns", Name: "s"} case *corev1.Secret: o.Data = map[string][]byte{ - xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), - xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), - xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), } } return nil }), }, track: nopUsage, - newDB: func(creds map[string][]byte, tls *string, _ *bool, cleartext bool) xsql.DB { + newDB: func(creds map[string][]byte, tls *string, _ *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB { if !cleartext { t.Error("expected cleartext=true for AWS IAM auth") } - if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { - t.Errorf("expected injected token as password, got %q", got) + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be cached in credentials") } - if tls == nil || *tls != "skip-verify" { - t.Errorf("expected TLS forced to skip-verify, got %v", tls) + if tls == nil || *tls != "true" { + t.Errorf("expected verified TLS by default, got %v", tls) } return mockDB{} }, @@ -205,8 +210,8 @@ func TestConnect(t *testing.T) { args: args{ mg: &v1alpha1.Database{ Spec: v1alpha1.DatabaseSpec{ - ResourceSpec: xpv1.ResourceSpec{ - ProviderConfigReference: &xpv1.Reference{}, + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{}, }, }, }, diff --git a/pkg/controller/cluster/mysql/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index f60c9d2f..d4ba1cf2 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -74,7 +74,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithReferenceResolver(managed.NewAPISimpleReferenceResolver(mgr.GetClient())), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), @@ -102,14 +102,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider 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, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Grant] = &connector{} @@ -127,9 +127,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -148,17 +146,18 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) cleartext := false + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } cleartext = true - // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via - // trust-manager or a mounted secret); honour a verifying tls mode if set, - // otherwise fall back to an encrypted (unverified) connection. + // Default IAM connections to verified TLS using the configured trust roots. tlsName = mysql.EnsureTLS(tlsName) } - return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext)}, nil + return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/mysql/grant/reconciler_test.go b/pkg/controller/cluster/mysql/grant/reconciler_test.go index c9499bd4..1d0610f9 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler_test.go +++ b/pkg/controller/cluster/mysql/grant/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/mysql/tls/tls.go b/pkg/controller/cluster/mysql/tls/tls.go index 56d46060..e028d176 100644 --- a/pkg/controller/cluster/mysql/tls/tls.go +++ b/pkg/controller/cluster/mysql/tls/tls.go @@ -2,6 +2,7 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "fmt" @@ -29,7 +30,7 @@ func LoadConfig(ctx context.Context, kube client.Client, providerConfigName stri return nil, err } - tlsName := fmt.Sprintf("custom-%s", providerConfigName) + tlsName := fmt.Sprintf("custom-cluster-%x", sha256.Sum256([]byte(providerConfigName))) err := registerTLS(ctx, kube, tlsName, cfg) if err != nil { return nil, err @@ -38,14 +39,14 @@ func LoadConfig(ctx context.Context, kube client.Client, providerConfigName stri } func validateTLSConfig(cfg *v1alpha1.TLSConfig) error { - if cfg == nil || - cfg.CACert.SecretRef.Name == "" || - cfg.CACert.SecretRef.Key == "" || - cfg.ClientCert.SecretRef.Name == "" || - cfg.ClientCert.SecretRef.Key == "" || - cfg.ClientKey.SecretRef.Name == "" || - cfg.ClientKey.SecretRef.Key == "" { - return fmt.Errorf("tlsConfig is required when tls=custom") + if cfg == nil || cfg.CACert.SecretRef.Name == "" || cfg.CACert.SecretRef.Key == "" { + return fmt.Errorf("tlsConfig.caCert is required when tls=custom") + } + cert, key := cfg.ClientCert.SecretRef, cfg.ClientKey.SecretRef + hasCert := cert != (xpv2.SecretKeySelector{}) + hasKey := key != (xpv2.SecretKeySelector{}) + if hasCert != hasKey || (hasCert && (cert.Name == "" || cert.Key == "" || key.Name == "" || key.Key == "")) { + return fmt.Errorf("tlsConfig.clientCert and clientKey must be provided together with complete secret references") } return nil } @@ -65,16 +66,19 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *v return fmt.Errorf("cannot append CA certificate to pool") } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err - } - - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + config := &tls.Config{ + MinVersion: tls.VersionTLS12, RootCAs: pool, - Certificates: []tls.Certificate{keyPair}, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // This is only required by integration tests and should never be used in production - }) + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // Explicit operator setting retained for compatibility. + } + if cfg.ClientCert.SecretRef.Name != "" { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + config.Certificates = []tls.Certificate{keyPair} + } + return mysql.RegisterTLSConfig(tlsName, config) } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *v1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 767e6c22..7aec2d65 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -70,7 +70,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -97,14 +97,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider 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, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.User] = &connector{} @@ -122,9 +122,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (MySQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -143,18 +141,19 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) cleartext := false + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } cleartext = true - // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via - // trust-manager or a mounted secret); honour a verifying tls mode if set, - // otherwise fall back to an encrypted (unverified) connection. + // Default IAM connections to verified TLS using the configured trust roots. tlsName = mysql.EnsureTLS(tlsName) } return &external{ - db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password), kube: c.kube, }, nil } diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index e7a6ee69..bc050a85 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 00c0cb9e..d850cebf 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -74,7 +74,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -101,14 +101,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Database] = &connector{} @@ -125,9 +125,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -141,14 +139,17 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } // Require encryption while preserving operator-configured verification. sslMode = postgresql.EnsureTLS(sslMode) } - return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode)}, nil + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 56285c24..3b3df074 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -74,17 +74,16 @@ func TestConnect(t *testing.T) { nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } // Stub the AWS IAM token injection so the success case needs no AWS access. - origInject := injectIAM - injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { - creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") - return nil + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil } - defer func() { injectIAM = origInject }() + defer func() { newIAMToken = origInject }() type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -184,24 +183,30 @@ func TestConnect(t *testing.T) { switch o := obj.(type) { case *v1alpha1.ProviderConfig: o.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth - o.Spec.Credentials.ConnectionSecretRef = &xpv1.SecretReference{Namespace: "ns", Name: "s"} + o.Spec.Credentials.ConnectionSecretRef = &xpv2.SecretReference{Namespace: "ns", Name: "s"} case *corev1.Secret: o.Data = map[string][]byte{ - xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), - xpv1.ResourceCredentialsSecretPortKey: []byte("5432"), - xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), } } return nil }), }, track: nopUsage, - newDB: func(creds map[string][]byte, _ string, sslmode string) xsql.DB { + newDB: func(creds map[string][]byte, _ string, sslmode string, password xsql.PasswordProvider) xsql.DB { if sslmode != "require" { t.Errorf("expected sslmode forced to require, got %q", sslmode) } - if got := string(creds[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { - t.Errorf("expected injected token as password, got %q", got) + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { + t.Error("token must not be cached in credentials") } return mockDB{} }, @@ -209,8 +214,8 @@ func TestConnect(t *testing.T) { args: args{ mg: &v1alpha1.Database{ Spec: v1alpha1.DatabaseSpec{ - ResourceSpec: xpv1.ResourceSpec{ - ProviderConfigReference: &xpv1.Reference{}, + ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ + ProviderConfigReference: &xpv2.Reference{}, }, }, }, @@ -235,12 +240,15 @@ func TestConnect(t *testing.T) { if mode == "" || mode == "disable" { wantMode = "require" } - tc.fields.newDB = func(creds map[string][]byte, _ string, got string) xsql.DB { + tc.fields.newDB = func(creds map[string][]byte, _ string, got string, password xsql.PasswordProvider) xsql.DB { if got != wantMode { t.Errorf("IAM sslmode: want %q, got %q", wantMode, got) } - if string(creds[xpv2.CredentialsSecretPasswordKey]) != "iam-token" { - t.Error("missing IAM token") + if password == nil { + t.Fatal("missing per-connection password provider") + } + if got, err := password(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) } return mockDB{} } diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index 6ce3265a..27a4f01b 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -72,7 +72,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -99,14 +99,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) (managed.TypedExternalClient[*v1alpha1.DefaultPrivileges], error) { @@ -121,9 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -144,13 +142,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } sslMode = postgresql.EnsureTLS(sslMode) } - return &external{db: c.newDB(secretData, database, sslMode)}, nil + return &external{db: c.newDB(secretData, database, sslMode, password)}, nil } type external struct { diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go index 8f828ddc..3e8256cd 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler_test.go @@ -81,7 +81,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -249,7 +249,7 @@ func TestConnectDatabaseSelection(t *testing.T) { }), }, track: func(ctx context.Context, mg resource.LegacyManaged) error { return nil }, - newDB: func(creds map[string][]byte, database string, sslmode string) xsql.DB { + newDB: func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { gotDatabase = database return mockDB{} }, diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index 8d9248e2..c998641a 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -64,7 +64,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -91,14 +91,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Extension] = &connector{} @@ -115,9 +115,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -131,8 +129,11 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } sslMode = postgresql.EnsureTLS(sslMode) @@ -141,10 +142,10 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage // 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, sslMode)}, nil + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil } - return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode)}, nil + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/cluster/postgresql/extension/reconciler_test.go b/pkg/controller/cluster/postgresql/extension/reconciler_test.go index 2baeceac..50e2c9ba 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler_test.go @@ -73,7 +73,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/grant/connect_test.go b/pkg/controller/cluster/postgresql/grant/connect_test.go index 0fa49a76..5ea46e3d 100644 --- a/pkg/controller/cluster/postgresql/grant/connect_test.go +++ b/pkg/controller/cluster/postgresql/grant/connect_test.go @@ -83,7 +83,7 @@ func TestConnectServerVersionUnavailable(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, _ string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, _ string, _ string, _ xsql.PasswordProvider) xsql.DB { return mockDB{ MockGetServerVersion: func(context.Context) (int, error) { return 0, errBoom }, } @@ -128,7 +128,7 @@ func TestConnectDatabaseGrantTargetsDefaultDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, @@ -177,7 +177,7 @@ func TestConnectObjectGrantTargetsGrantDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.LegacyManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index 86b56429..20e7587d 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -82,15 +82,15 @@ const ( versionUnknown = 0 ) -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client log logging.Logger track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } type external struct { @@ -123,9 +123,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -138,13 +136,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } sslMode = postgresql.EnsureTLS(sslMode) } - xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode) + xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode, password) // A server that cannot report its version is not a reason to fail: only // table grants are version dependent, and ExpandPrivilegesWithVersion @@ -1191,7 +1192,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), diff --git a/pkg/controller/cluster/postgresql/grant/reconciler_test.go b/pkg/controller/cluster/postgresql/grant/reconciler_test.go index f90aae47..36ad2052 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler_test.go @@ -89,7 +89,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 8a462cb7..b2cc64df 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -76,7 +76,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -103,15 +103,15 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client apiReader client.Reader track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*v1alpha1.Role] = &connector{} @@ -128,9 +128,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -144,14 +142,17 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } sslMode = postgresql.EnsureTLS(sslMode) } return &external{ - db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode), + db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/cluster/postgresql/role/reconciler_test.go b/pkg/controller/cluster/postgresql/role/reconciler_test.go index afd1f63e..78c699c8 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/role/reconciler_test.go @@ -90,7 +90,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -1552,7 +1552,7 @@ func TestConnectWithSecretKeyMapping(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } cases := map[string]struct { @@ -1648,7 +1648,7 @@ func TestConnectWithSecretKeyMapping(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { var captured map[string][]byte - tc.fields.newDB = func(creds map[string][]byte, database string, sslmode string) xsql.DB { + tc.fields.newDB = func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { captured = creds return mockDB{} } diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 90786754..1c01f7a4 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -68,7 +68,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -97,14 +97,14 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { var _ managed.TypedExternalConnector[*v1alpha1.Schema] = &connector{} -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.TypedExternalClient[*v1alpha1.Schema], error) { @@ -119,9 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T return nil, errors.Wrap(err, errGetPC) } - // We don't need to check the credentials source because we currently only - // support one source (PostgreSQLConnectionSecret), which is required and - // enforced by the ProviderConfig schema. + // Load connection details for either password or IAM authentication. ref := pc.Spec.Credentials.ConnectionSecretRef if ref == nil { return nil, errors.New(errNoSecretRef) @@ -139,13 +137,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) sslMode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, pc.Spec.Credentials.Region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { return nil, errors.Wrap(err, errGenerateIAMToken) } sslMode = postgresql.EnsureTLS(sslMode) } - return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode)}, nil + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil } var _ managed.TypedExternalClient[*v1alpha1.Schema] = &external{} diff --git a/pkg/controller/cluster/postgresql/schema/reconciler_test.go b/pkg/controller/cluster/postgresql/schema/reconciler_test.go index e469b3ba..e3ff06f9 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler_test.go @@ -80,7 +80,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.LegacyManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/database/reconciler.go b/pkg/controller/namespaced/mysql/database/reconciler.go index d9791728..e3445882 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler.go +++ b/pkg/controller/namespaced/mysql/database/reconciler.go @@ -59,7 +59,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -90,7 +90,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Database] = &connector{} @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/database/reconciler_test.go b/pkg/controller/namespaced/mysql/database/reconciler_test.go index 3f3ce4ff..474dba09 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/database/reconciler_test.go @@ -70,7 +70,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/grant/reconciler.go b/pkg/controller/namespaced/mysql/grant/reconciler.go index f4b7e539..2e3d7648 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler.go @@ -68,7 +68,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithReferenceResolver(managed.NewAPISimpleReferenceResolver(mgr.GetClient())), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), @@ -99,7 +99,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Grant] = &connector{} @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Grant) ( return nil, errors.Wrap(err, errTLSConfig) } - return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext)}, nil + return &external{db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/grant/reconciler_test.go b/pkg/controller/namespaced/mysql/grant/reconciler_test.go index 85030dbf..87af983a 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 35427dd7..477b69e9 100644 --- a/pkg/controller/namespaced/mysql/provider/provider.go +++ b/pkg/controller/namespaced/mysql/provider/provider.go @@ -7,6 +7,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "context" + "fmt" "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" @@ -18,6 +19,7 @@ import ( ) type ProviderInfo struct { + PasswordProvider xsql.PasswordProvider ProviderConfigName string SecretData map[string][]byte TLS *string @@ -27,9 +29,9 @@ type ProviderInfo struct { Cleartext bool } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( @@ -102,19 +104,21 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) cleartext := false + var password xsql.PasswordProvider if source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, region, secretData) + if err != nil { return ProviderInfo{}, errors.GenerateIAMTokenError(err) } cleartext = true - // IAM auth requires TLS. The operator supplies the RDS CA (e.g. via - // trust-manager or a mounted secret); honour a verifying tls mode if set, - // otherwise fall back to an encrypted (unverified) connection. + // Default IAM connections to verified TLS using the configured trust roots. tlsMode = mysql.EnsureTLS(tlsMode) } return ProviderInfo{ - ProviderConfigName: mg.GetProviderConfigReference().Name, + PasswordProvider: password, + ProviderConfigName: fmt.Sprintf("%s/%s/%s", mg.GetProviderConfigReference().Kind, mg.GetNamespace(), mg.GetProviderConfigReference().Name), SecretData: secretData, TLS: tlsMode, TLSConfig: tlsConfig, diff --git a/pkg/controller/namespaced/mysql/provider/provider_test.go b/pkg/controller/namespaced/mysql/provider/provider_test.go index 40afd29e..db21f73f 100644 --- a/pkg/controller/namespaced/mysql/provider/provider_test.go +++ b/pkg/controller/namespaced/mysql/provider/provider_test.go @@ -18,73 +18,106 @@ package provider import ( "context" + "fmt" "testing" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/crossplane/crossplane-runtime/v2/apis/common" - xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" - xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" "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" ) -// TestGetProviderConfigAWSIAMAuth verifies the centralized IAM injection: when -// the source is AWSIAMAuth the token is injected as the password, the Cleartext -// flag is set, and TLS is forced on. This covers all three namespaced MySQL -// reconcilers, which obtain credentials exclusively through GetProviderConfig. -func TestGetProviderConfigAWSIAMAuth(t *testing.T) { - // Stub the AWS IAM token injection so the test needs no AWS access. - origInject := injectIAM - injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { - creds[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("iam-token") - return nil +// TestGetProviderConfigTLS covers both ProviderConfig kinds and ensures IAM +// enforces encryption without downgrading verification or changing password auth. +func TestGetProviderConfigTLS(t *testing.T) { + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil } - defer func() { injectIAM = origInject }() - - 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 = xpv1.LocalSecretReference{Name: "s"} - case *corev1.Secret: - o.Data = map[string][]byte{ - xpv1.ResourceCredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), - xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), - xpv1.ResourceCredentialsSecretUserKey: []byte("crossplane_admin"), - } + t.Cleanup(func() { newIAMToken = origInject }) + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, iam := range []bool{false, true} { + for _, mode := range []string{"unset", "", "false", "preferred", "true", "skip-verify", "custom"} { + t.Run(fmt.Sprintf("%s/IAM=%t/%s", kind, iam, mode), func(t *testing.T) { + var sslMode *string + if mode != "unset" { + sslMode = &mode + } + source := v1alpha1.CredentialsSourceMySQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.TLS = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "s"} + case *v1alpha1.ClusterProviderConfig: + o.Spec.TLS = sslMode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "s", Namespace: "default"} + case *corev1.Secret: + o.Data = map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example.rds.amazonaws.com"), + xpv2.CredentialsSecretPortKey: []byte("3306"), + xpv2.CredentialsSecretUserKey: []byte("crossplane_admin"), + xpv2.CredentialsSecretPasswordKey: []byte("password"), + } + } + return nil + })} + mg := &v1alpha1.Database{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "example"}, + }}, + } + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + wantMode := mode + if mode == "unset" { + wantMode = "" + } + if iam && (wantMode == "" || wantMode == "false" || wantMode == "preferred") { + wantMode = "true" + } + gotMode := "" + if info.TLS != nil { + gotMode = *info.TLS + } + if gotMode != wantMode { + t.Errorf("TLS: want %q, got %q", wantMode, gotMode) + } + if info.Cleartext != iam { + t.Error("cleartext plugin flag must match IAM source") + } + if !iam && mode == "unset" && info.TLS != nil { + t.Error("password auth should preserve nil TLS") + } + if iam { + if info.PasswordProvider == nil { + t.Fatal("missing per-connection token provider") + } + if got, err := info.PasswordProvider(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + } else if info.PasswordProvider != nil { + t.Error("password auth must not use IAM provider") + } + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "password" { + t.Errorf("Secret password changed: %q", got) + } + }) } - return nil - }), - } - - mg := &v1alpha1.Database{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, - Spec: v1alpha1.DatabaseSpec{ - ManagedResourceSpec: xpv2.ManagedResourceSpec{ - ProviderConfigReference: &common.ProviderConfigReference{ - Kind: v1alpha1.ProviderConfigKind, - Name: "example", - }, - }, - }, - } - - info, err := GetProviderConfig(context.Background(), kube, mg) - if err != nil { - t.Fatalf("GetProviderConfig(...): unexpected error: %v", err) - } - if !info.Cleartext { - t.Error("expected Cleartext=true for AWS IAM auth") - } - if got := string(info.SecretData[xpv1.ResourceCredentialsSecretPasswordKey]); got != "iam-token" { - t.Errorf("expected injected token as password, got %q", got) - } - if info.TLS == nil || *info.TLS != "skip-verify" { - t.Errorf("expected TLS forced to skip-verify, got %v", info.TLS) + } } } diff --git a/pkg/controller/namespaced/mysql/tls/tls.go b/pkg/controller/namespaced/mysql/tls/tls.go index 2daa7120..2f121ef5 100644 --- a/pkg/controller/namespaced/mysql/tls/tls.go +++ b/pkg/controller/namespaced/mysql/tls/tls.go @@ -2,6 +2,7 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "fmt" @@ -36,7 +37,7 @@ func LoadConfig( return nil, err } - tlsName := fmt.Sprintf("custom-%s", providerConfigName) + tlsName := fmt.Sprintf("custom-namespaced-%x", sha256.Sum256([]byte(providerConfigName))) err := registerTLS(ctx, kube, tlsName, tlsConfig) if err != nil { return nil, err @@ -45,14 +46,14 @@ func LoadConfig( } func validateTLSConfig(cfg *namespacedv1alpha1.TLSConfig) error { - if cfg == nil || - cfg.CACert.SecretRef.Name == "" || - cfg.CACert.SecretRef.Key == "" || - cfg.ClientCert.SecretRef.Name == "" || - cfg.ClientCert.SecretRef.Key == "" || - cfg.ClientKey.SecretRef.Name == "" || - cfg.ClientKey.SecretRef.Key == "" { - return fmt.Errorf("tlsConfig is required when tls=custom") + if cfg == nil || cfg.CACert.SecretRef.Name == "" || cfg.CACert.SecretRef.Key == "" { + return fmt.Errorf("tlsConfig.caCert is required when tls=custom") + } + cert, key := cfg.ClientCert.SecretRef, cfg.ClientKey.SecretRef + hasCert := cert != (xpv2.SecretKeySelector{}) + hasKey := key != (xpv2.SecretKeySelector{}) + if hasCert != hasKey || (hasCert && (cert.Name == "" || cert.Key == "" || key.Name == "" || key.Key == "")) { + return fmt.Errorf("tlsConfig.clientCert and clientKey must be provided together with complete secret references") } return nil } @@ -72,16 +73,19 @@ func registerTLS(ctx context.Context, kube client.Client, tlsName string, cfg *n return fmt.Errorf("cannot append CA certificate to pool") } - keyPair, err := getClientKeyPair(ctx, kube, cfg) - if err != nil { - return err - } - - return mysql.RegisterTLSConfig(tlsName, &tls.Config{ + config := &tls.Config{ + MinVersion: tls.VersionTLS12, RootCAs: pool, - Certificates: []tls.Certificate{keyPair}, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // This is only required by integration tests and should never be used in production - }) + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // Explicit operator setting retained for compatibility. + } + if cfg.ClientCert.SecretRef.Name != "" { + keyPair, err := getClientKeyPair(ctx, kube, cfg) + if err != nil { + return err + } + config.Certificates = []tls.Certificate{keyPair} + } + return mysql.RegisterTLSConfig(tlsName, config) } func getClientKeyPair(ctx context.Context, kube client.Client, cfg *namespacedv1alpha1.TLSConfig) (tls.Certificate, error) { diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 72c4b94b..c6c7e1cd 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -64,7 +64,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: mysql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,7 +94,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.User] = &connector{} @@ -117,7 +117,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.User) (m } return &external{ - db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext), + db: c.newDB(providerInfo.SecretData, tlsName, mg.Spec.ForProvider.BinLog, providerInfo.Cleartext, providerInfo.PasswordProvider), kube: c.kube, }, nil } diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index f981bf00..c0bf6bd5 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -79,7 +79,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/database/reconciler.go b/pkg/controller/namespaced/postgresql/database/reconciler.go index cb57d912..e1b4cadd 100644 --- a/pkg/controller/namespaced/postgresql/database/reconciler.go +++ b/pkg/controller/namespaced/postgresql/database/reconciler.go @@ -68,7 +68,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -98,7 +98,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Database] = &connector{} @@ -115,7 +115,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Database return nil, err } - return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/postgresql/database/reconciler_test.go b/pkg/controller/namespaced/postgresql/database/reconciler_test.go index c7dca4b8..01a31940 100644 --- a/pkg/controller/namespaced/postgresql/database/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/database/reconciler_test.go @@ -78,7 +78,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go b/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go index aea771e7..9afa5581 100644 --- a/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/namespaced/postgresql/default_privileges/reconciler.go @@ -69,7 +69,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -99,7 +99,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) (managed.TypedExternalClient[*v1alpha1.DefaultPrivileges], error) { @@ -121,7 +121,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) database = *mg.Spec.ForProvider.Database } - return &external{db: c.newDB(providerInfo.SecretData, database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct { diff --git a/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go b/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go index 6f4a0844..abca10d3 100644 --- a/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/default_privileges/reconciler_test.go @@ -83,7 +83,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { @@ -297,7 +297,7 @@ func TestConnectDatabaseSelection(t *testing.T) { }), }, track: func(ctx context.Context, mg resource.ModernManaged) error { return nil }, - newDB: func(creds map[string][]byte, database string, sslmode string) xsql.DB { + newDB: func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB { gotDatabase = database return mockDB{} }, diff --git a/pkg/controller/namespaced/postgresql/extension/reconciler.go b/pkg/controller/namespaced/postgresql/extension/reconciler.go index ab4cffec..ff863593 100644 --- a/pkg/controller/namespaced/postgresql/extension/reconciler.go +++ b/pkg/controller/namespaced/postgresql/extension/reconciler.go @@ -58,7 +58,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -88,7 +88,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Extension] = &connector{} @@ -108,10 +108,10 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Extensio // We do not want to create an extension on the default DB // if the user was expecting a database name to be resolved. if mg.Spec.ForProvider.Database != nil { - return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } - return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/postgresql/extension/reconciler_test.go b/pkg/controller/namespaced/postgresql/extension/reconciler_test.go index 321889ac..d96c8261 100644 --- a/pkg/controller/namespaced/postgresql/extension/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/extension/reconciler_test.go @@ -74,7 +74,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/grant/connect_test.go b/pkg/controller/namespaced/postgresql/grant/connect_test.go index 6dc66297..83871377 100644 --- a/pkg/controller/namespaced/postgresql/grant/connect_test.go +++ b/pkg/controller/namespaced/postgresql/grant/connect_test.go @@ -89,7 +89,7 @@ func TestConnectServerVersionUnavailable(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, _ string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, _ string, _ string, _ xsql.PasswordProvider) xsql.DB { return mockDB{ MockGetServerVersion: func(context.Context) (int, error) { return 0, errBoom }, } @@ -131,7 +131,7 @@ func TestConnectDatabaseGrantTargetsDefaultDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, @@ -180,7 +180,7 @@ func TestConnectObjectGrantTargetsGrantDatabase(t *testing.T) { kube: kubeWithSecret("postgres"), log: logging.NewNopLogger(), track: func(context.Context, resource.ModernManaged) error { return nil }, - newDB: func(_ map[string][]byte, database string, _ string) xsql.DB { + newDB: func(_ map[string][]byte, database string, _ string, _ xsql.PasswordProvider) xsql.DB { connectedTo = database return mockDB{} }, diff --git a/pkg/controller/namespaced/postgresql/grant/reconciler.go b/pkg/controller/namespaced/postgresql/grant/reconciler.go index 9148b8ef..b3dca5eb 100644 --- a/pkg/controller/namespaced/postgresql/grant/reconciler.go +++ b/pkg/controller/namespaced/postgresql/grant/reconciler.go @@ -79,7 +79,7 @@ type connector struct { kube client.Client log logging.Logger track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } type external struct { @@ -112,7 +112,7 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, err } - xdb := c.newDB(providerInfo.SecretData, connectDatabase(mg.Spec.ForProvider, providerInfo.DefaultDatabase), clients.ToString(providerInfo.SSLMode)) + xdb := c.newDB(providerInfo.SecretData, connectDatabase(mg.Spec.ForProvider, providerInfo.DefaultDatabase), clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider) // A server that cannot report its version is not a reason to fail: only // table grants are version dependent, and ExpandPrivilegesWithVersion @@ -1127,7 +1127,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), log: o.Logger.WithValues("controller", name), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), diff --git a/pkg/controller/namespaced/postgresql/grant/reconciler_test.go b/pkg/controller/namespaced/postgresql/grant/reconciler_test.go index 5a656d20..5742dedf 100644 --- a/pkg/controller/namespaced/postgresql/grant/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/grant/reconciler_test.go @@ -90,7 +90,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index 87d32203..e406b28b 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -19,15 +19,16 @@ import ( ) type ProviderInfo struct { + PasswordProvider xsql.PasswordProvider ProviderConfigName string SecretData map[string][]byte DefaultDatabase string SSLMode *string } -// injectIAM generates and injects an AWS IAM auth token. It is a package -// variable so tests can replace it with a stub. -var injectIAM = awsiam.Inject +// newIAMToken prepares a token provider used for every physical connection. +// Tests replace this factory to avoid AWS access. +var newIAMToken = awsiam.NewTokenProvider func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( @@ -99,8 +100,11 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) + var password xsql.PasswordProvider if source == v1alpha1.CredentialsSourceAWSIAMAuth { - if err := injectIAM(ctx, region, secretData); err != nil { + var err error + password, err = newIAMToken(ctx, region, secretData) + if err != nil { return ProviderInfo{}, provErrors.GenerateIAMTokenError(err) } // Require encryption while preserving operator-configured verification. @@ -109,6 +113,7 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode } return ProviderInfo{ + PasswordProvider: password, ProviderConfigName: mg.GetProviderConfigReference().Name, SecretData: secretData, DefaultDatabase: defaultDatabase, diff --git a/pkg/controller/namespaced/postgresql/provider/provider_test.go b/pkg/controller/namespaced/postgresql/provider/provider_test.go index 93a5f74a..6fe3cf4a 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider_test.go +++ b/pkg/controller/namespaced/postgresql/provider/provider_test.go @@ -21,6 +21,8 @@ import ( "fmt" "testing" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -34,12 +36,11 @@ import ( // TestGetProviderConfigTLS covers both ProviderConfig kinds and ensures IAM // enforces encryption without downgrading verification or changing password auth. func TestGetProviderConfigTLS(t *testing.T) { - origInject := injectIAM - injectIAM = func(_ context.Context, _ *string, creds map[string][]byte) error { - creds[xpv2.CredentialsSecretPasswordKey] = []byte("iam-token") - return nil + origInject := newIAMToken + newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "iam-token", nil }, nil } - t.Cleanup(func() { injectIAM = origInject }) + t.Cleanup(func() { newIAMToken = origInject }) for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { for _, iam := range []bool{false, true} { for _, mode := range []string{"unset", "", "disable", "require", "verify-ca", "verify-full"} { @@ -99,12 +100,18 @@ func TestGetProviderConfigTLS(t *testing.T) { if !iam && mode == "unset" && info.SSLMode != nil { t.Error("password auth should preserve nil SSLMode") } - wantPassword := "password" if iam { - wantPassword = "iam-token" + if info.PasswordProvider == nil { + t.Fatal("missing per-connection token provider") + } + if got, err := info.PasswordProvider(context.Background()); err != nil || got != "iam-token" { + t.Errorf("token: %q, %v", got, err) + } + } else if info.PasswordProvider != nil { + t.Error("password auth must not use IAM provider") } - if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != wantPassword { - t.Errorf("password: want %q, got %q", wantPassword, got) + if got := string(info.SecretData[xpv2.CredentialsSecretPasswordKey]); got != "password" { + t.Errorf("Secret password changed: %q", got) } }) } diff --git a/pkg/controller/namespaced/postgresql/role/reconciler.go b/pkg/controller/namespaced/postgresql/role/reconciler.go index 7f3b4044..1f4136f5 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler.go @@ -70,7 +70,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), apiReader: mgr.GetAPIReader(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -101,7 +101,7 @@ type connector struct { kube client.Client apiReader client.Reader track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } var _ managed.TypedExternalConnector[*namespacedv1alpha1.Role] = &connector{} @@ -119,7 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Role) (m } return &external{ - db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode)), + db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/namespaced/postgresql/role/reconciler_test.go b/pkg/controller/namespaced/postgresql/role/reconciler_test.go index cca25719..eadf12bb 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler_test.go @@ -91,7 +91,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/postgresql/schema/reconciler.go b/pkg/controller/namespaced/postgresql/schema/reconciler.go index 53766580..8be4954b 100644 --- a/pkg/controller/namespaced/postgresql/schema/reconciler.go +++ b/pkg/controller/namespaced/postgresql/schema/reconciler.go @@ -62,7 +62,7 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { t := resource.NewProviderConfigUsageTracker(mgr.GetClient(), &namespacedv1alpha1.ProviderConfigUsage{}) reconcilerOptions := []managed.ReconcilerOption{ - managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.New}), + managed.WithTypedExternalConnector(&connector{kube: mgr.GetClient(), track: t.Track, newDB: postgresql.NewWithPasswordProvider}), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithPollInterval(o.PollInterval), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -94,7 +94,7 @@ var _ managed.TypedExternalConnector[*namespacedv1alpha1.Schema] = &connector{} type connector struct { kube client.Client track func(ctx context.Context, mg resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, password xsql.PasswordProvider) xsql.DB } func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Schema) (managed.TypedExternalClient[*namespacedv1alpha1.Schema], error) { @@ -113,7 +113,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Schema) return nil, errors.New(errNoDatabase) } - return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode))}, nil + return &external{db: c.newDB(providerInfo.SecretData, *mg.Spec.ForProvider.Database, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider)}, nil } var _ managed.TypedExternalClient[*namespacedv1alpha1.Schema] = &external{} diff --git a/pkg/controller/namespaced/postgresql/schema/reconciler_test.go b/pkg/controller/namespaced/postgresql/schema/reconciler_test.go index 601c3554..a0179a52 100644 --- a/pkg/controller/namespaced/postgresql/schema/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/schema/reconciler_test.go @@ -82,7 +82,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, database string, sslmode string) xsql.DB + newDB func(creds map[string][]byte, database string, sslmode string, _ xsql.PasswordProvider) xsql.DB } type args struct { From bf4e6c2108c2abb7f62991705c17025563ea231f Mon Sep 17 00:00:00 2001 From: michielvha Date: Fri, 23 Oct 2026 17:26:42 +0200 Subject: [PATCH 09/11] feat: support PostgreSQL CA Secret selectors Signed-off-by: michielvha --- .../postgresql/v1alpha1/provider_types.go | 17 + .../v1alpha1/zz_generated.deepcopy.go | 37 ++ .../v1alpha1/cluster_provider_types.go | 17 + .../postgresql/v1alpha1/provider_types.go | 17 + .../v1alpha1/zz_generated.deepcopy.go | 74 ++++ cluster/local/integration_tests.sh | 13 + cluster/local/postgres.server.yaml | 2 + cluster/local/postgres.tls.server.yaml | 72 ++++ cluster/local/postgresdb_functions.sh | 285 ++++++++++++++- docs/aws-iam-auth.md | 178 ++++------ .../aws-iam/deployment-runtime-config.yaml | 28 -- examples/cluster/mysql/config_iam.yaml | 13 +- examples/cluster/postgresql/config_iam.yaml | 11 +- examples/namespaced/mysql/config_iam.yaml | 11 +- .../postgresql/clusterconfig_iam.yaml | 18 + .../namespaced/postgresql/config_iam.yaml | 8 +- ...sql.sql.crossplane.io_providerconfigs.yaml | 33 ++ ....crossplane.io_clusterproviderconfigs.yaml | 33 ++ ...l.sql.m.crossplane.io_providerconfigs.yaml | 28 ++ pkg/clients/postgresql/postgresql.go | 21 +- pkg/clients/postgresql/tls.go | 52 +++ pkg/clients/postgresql/tls_test.go | 89 ++++- .../cluster/postgresql/database/reconciler.go | 11 + .../postgresql/database/reconciler_test.go | 1 + .../default_privileges/reconciler.go | 11 + .../postgresql/extension/reconciler.go | 10 + .../cluster/postgresql/grant/reconciler.go | 11 + .../cluster/postgresql/role/reconciler.go | 13 +- .../cluster/postgresql/schema/reconciler.go | 11 + .../namespaced/postgresql/provider/ca_test.go | 127 +++++++ .../postgresql/provider/provider.go | 49 ++- .../namespaced/postgresql/role/reconciler.go | 2 +- pkg/controller/postgresql/tls/gc.go | 111 ++++++ .../postgresql/tls/lifecycle_test.go | 330 ++++++++++++++++++ pkg/controller/postgresql/tls/tls.go | 130 +++++++ pkg/controller/postgresql/tls/tls_test.go | 136 ++++++++ pkg/controller/sql.go | 2 + 37 files changed, 1824 insertions(+), 188 deletions(-) create mode 100644 cluster/local/postgres.tls.server.yaml delete mode 100644 examples/aws-iam/deployment-runtime-config.yaml create mode 100644 examples/namespaced/postgresql/clusterconfig_iam.yaml create mode 100644 pkg/clients/postgresql/tls.go create mode 100644 pkg/controller/namespaced/postgresql/provider/ca_test.go create mode 100644 pkg/controller/postgresql/tls/gc.go create mode 100644 pkg/controller/postgresql/tls/lifecycle_test.go create mode 100644 pkg/controller/postgresql/tls/tls.go create mode 100644 pkg/controller/postgresql/tls/tls_test.go diff --git a/apis/cluster/postgresql/v1alpha1/provider_types.go b/apis/cluster/postgresql/v1alpha1/provider_types.go index b53aec48..2bdd5bda 100644 --- a/apis/cluster/postgresql/v1alpha1/provider_types.go +++ b/apis/cluster/postgresql/v1alpha1/provider_types.go @@ -36,6 +36,23 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig selects the CA bundle for PostgreSQL server verification. +type TLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert TLSSecret `json:"caCert"` +} + +// TLSSecret selects a Secret key containing a PEM certificate bundle. +type TLSSecret struct { + // SecretRef selects the CA bundle. Namespace, name and key are required. + SecretRef xpv2.SecretKeySelector `json:"secretRef"` } const ( diff --git a/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go index 594a0a10..8dd4dd72 100644 --- a/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -780,6 +780,11 @@ func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. @@ -1307,3 +1312,35 @@ func (in *SecretKeyMapping) DeepCopy() *SecretKeyMapping { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecret) DeepCopyInto(out *TLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecret. +func (in *TLSSecret) DeepCopy() *TLSSecret { + if in == nil { + return nil + } + out := new(TLSSecret) + in.DeepCopyInto(out) + return out +} diff --git a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go index 1a5bff97..5e142d6d 100644 --- a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go @@ -36,6 +36,23 @@ type ClusterProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *ClusterTLSConfig `json:"tlsConfig,omitempty"` +} + +// ClusterTLSConfig selects the CA bundle for a ClusterProviderConfig. +type ClusterTLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert ClusterTLSSecret `json:"caCert"` +} + +// ClusterTLSSecret selects a Secret key for a cluster-scoped configuration. +type ClusterTLSSecret struct { + // SecretRef selects the CA bundle. Namespace, name and key are required. + SecretRef xpv2.SecretKeySelector `json:"secretRef"` } // ClusterProviderCredentials required to authenticate. diff --git a/apis/namespaced/postgresql/v1alpha1/provider_types.go b/apis/namespaced/postgresql/v1alpha1/provider_types.go index a5f030f2..1913c9d9 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -36,6 +36,23 @@ type ProviderConfigSpec struct { // +kubebuilder:default=verify-full // +kubebuilder:validation:Optional SSLMode *string `json:"sslMode,omitempty"` + + // TLSConfig selects the CA bundle used to verify the PostgreSQL server. + // When set, sslMode must be verify-ca or verify-full. + // +optional + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` +} + +// TLSConfig selects the CA bundle for PostgreSQL server verification. +type TLSConfig struct { + // CACert contains the PEM-encoded certificates trusted for this connection. + CACert TLSSecret `json:"caCert"` +} + +// TLSSecret selects a Secret key containing a PEM certificate bundle. +type TLSSecret struct { + // SecretRef selects the CA bundle. The Secret must be in the ProviderConfig namespace. + SecretRef xpv2.LocalSecretKeySelector `json:"secretRef"` } type PostgreSQLConnectionSource string diff --git a/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go index 336f51d5..cbd7d035 100644 --- a/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,11 @@ func (in *ClusterProviderConfigSpec) DeepCopyInto(out *ClusterProviderConfigSpec *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(ClusterTLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderConfigSpec. @@ -135,6 +140,38 @@ func (in *ClusterProviderCredentials) DeepCopy() *ClusterProviderCredentials { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterTLSConfig) DeepCopyInto(out *ClusterTLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTLSConfig. +func (in *ClusterTLSConfig) DeepCopy() *ClusterTLSConfig { + if in == nil { + return nil + } + out := new(ClusterTLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterTLSSecret) DeepCopyInto(out *ClusterTLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTLSSecret. +func (in *ClusterTLSSecret) DeepCopy() *ClusterTLSSecret { + if in == nil { + return nil + } + out := new(ClusterTLSSecret) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -902,6 +939,11 @@ func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = new(string) **out = **in } + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. @@ -1425,3 +1467,35 @@ func (in *SecretKeyMapping) DeepCopy() *SecretKeyMapping { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + out.CACert = in.CACert +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecret) DeepCopyInto(out *TLSSecret) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecret. +func (in *TLSSecret) DeepCopy() *TLSSecret { + if in == nil { + return nil + } + out := new(TLSSecret) + in.DeepCopyInto(out) + return out +} diff --git a/cluster/local/integration_tests.sh b/cluster/local/integration_tests.sh index 903eb896..7b97f0aa 100755 --- a/cluster/local/integration_tests.sh +++ b/cluster/local/integration_tests.sh @@ -51,15 +51,26 @@ MARIADB_ROOT_PW=$(openssl rand -base64 32) MARIADB_TEST_PW=$(openssl rand -base64 32) MSSQL_SA_PW="$(openssl rand -base64 16)Aa1!" # MSSQL requires complex password +# Remove generated private keys even when retaining the test cluster for debugging. +POSTGRES_TLS_DIR="" +cleanup_postgres_tls_files() { + if [[ -n "${POSTGRES_TLS_DIR}" ]]; then + rm -rf "${POSTGRES_TLS_DIR}" + fi +} + # cleanup on exit if [ "$skipcleanup" != true ]; then function cleanup { echo_step "Cleaning up..." + cleanup_postgres_tls_files export KUBECONFIG= cleanup_cluster } trap cleanup EXIT +else + trap cleanup_postgres_tls_files EXIT fi # Global variable to control API type @@ -456,6 +467,8 @@ TLS=false API_TYPE="cluster" run_test integration_tests_mariadb TLS=false API_TYPE="cluster" run_test integration_tests_postgres TLS=false API_TYPE="namespaced" run_test integration_tests_postgres +TLS=true API_TYPE="cluster" run_test integration_tests_postgres +TLS=true API_TYPE="namespaced" run_test integration_tests_postgres # no TLS=false variant - MSSQL uses built-in encryption TLS=true API_TYPE="cluster" run_test integration_tests_mssql diff --git a/cluster/local/postgres.server.yaml b/cluster/local/postgres.server.yaml index bbafcff8..f51f8e8b 100644 --- a/cluster/local/postgres.server.yaml +++ b/cluster/local/postgres.server.yaml @@ -44,6 +44,8 @@ spec: exec: command: - pg_isready + - -h + - 127.0.0.1 - -U - postgres initialDelaySeconds: 5 diff --git a/cluster/local/postgres.tls.server.yaml b/cluster/local/postgres.tls.server.yaml new file mode 100644 index 00000000..c7803ca6 --- /dev/null +++ b/cluster/local/postgres.tls.server.yaml @@ -0,0 +1,72 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgresdb-postgresql + namespace: default +spec: + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgresdb-postgresql +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgresdb-postgresql + namespace: default +spec: + serviceName: postgresdb-postgresql + replicas: 1 + selector: + matchLabels: + app: postgresdb-postgresql + template: + metadata: + labels: + app: postgresdb-postgresql + spec: + containers: + - name: postgresql + image: postgres:${POSTGRES_VERSION} + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgresdb-creds + key: password + ports: + - containerPort: 5432 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql + - name: server-tls + mountPath: /etc/postgresql/tls + readOnly: true + readinessProbe: + exec: + command: + - pg_isready + - -h + - 127.0.0.1 + - -U + - postgres + initialDelaySeconds: 5 + periodSeconds: 5 + args: + - postgres + - -c + - ssl=on + - -c + - ssl_cert_file=/etc/postgresql/tls/server-cert.pem + - -c + - ssl_key_file=/etc/postgresql/tls/server-key.pem + volumes: + - name: data + emptyDir: {} + - name: server-tls + secret: + secretName: postgres-server-tls + defaultMode: 288 + securityContext: + fsGroup: 999 diff --git a/cluster/local/postgresdb_functions.sh b/cluster/local/postgresdb_functions.sh index 48622c1b..2c1df4ec 100644 --- a/cluster/local/postgresdb_functions.sh +++ b/cluster/local/postgresdb_functions.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -setup_postgresdb_no_tls() { +setup_postgresdb() { echo_step "Installing PostgresDB into default namespace" postgres_root_pw=$(LC_ALL=C tr -cd "A-Za-z0-9" "${POSTGRES_TLS_DIR}/ca.cnf" < /dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -subj "/CN=postgresdb-postgresql.default.svc.cluster.local" \ + -keyout "${POSTGRES_TLS_DIR}/server-key.pem" -out "${POSTGRES_TLS_DIR}/server.csr" > /dev/null 2>&1 + echo "subjectAltName=DNS:postgresdb-postgresql.default.svc.cluster.local" > "${POSTGRES_TLS_DIR}/server.ext" + openssl x509 -req -days 1 -in "${POSTGRES_TLS_DIR}/server.csr" \ + -CA "${POSTGRES_TLS_DIR}/ca-cert.pem" -CAkey "${POSTGRES_TLS_DIR}/ca-key.pem" -CAcreateserial \ + -extfile "${POSTGRES_TLS_DIR}/server.ext" -out "${POSTGRES_TLS_DIR}/server-cert.pem" > /dev/null 2>&1 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=untrusted-e2e-ca" \ + -keyout "${POSTGRES_TLS_DIR}/wrong-key.pem" -out "${POSTGRES_TLS_DIR}/wrong-ca.pem" > /dev/null 2>&1 + "${KUBECTL}" create secret generic postgres-server-tls \ + --from-file=server-cert.pem="${POSTGRES_TLS_DIR}/server-cert.pem" \ + --from-file=server-key.pem="${POSTGRES_TLS_DIR}/server-key.pem" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/ca-cert.pem" +} + +replace_postgres_ca() { + "${KUBECTL}" -n "$1" create secret generic postgres-ca \ + --from-file=ca-bundle.pem="$2" --dry-run=client -o yaml | "${KUBECTL}" apply --server-side -f - +} + +# Wait for the expected failure itself, rather than accepting an unrelated +# transient False condition while the provider is switching configurations. +wait_postgres_tls_rejection() { + local namespace="$1" resource="$2" expected="$3" + local deadline=$((SECONDS + 90)) status message + while (( SECONDS < deadline )); do + status=$("${KUBECTL}" -n "${namespace}" get "${resource}" -o 'jsonpath={.status.conditions[?(@.type=="Synced")].status}') + message=$("${KUBECTL}" -n "${namespace}" get "${resource}" -o 'jsonpath={.status.conditions[?(@.type=="Synced")].message}') + if [[ "${status}" == "False" && "${message}" == *"${expected}"* ]]; then + return 0 + fi + sleep 1 + done + echo_error "Expected TLS rejection containing '${expected}', got ${status}: ${message}" +} + +check_postgres_ca_rotation() { + echo_step "Checking PostgreSQL CA Secret rejection and rotation without provider restart" + local db="database.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/db1" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/wrong-ca.pem" + "${KUBECTL}" annotate "${db}" test.sql.crossplane.io/ca-rotation=wrong --overwrite + wait_postgres_tls_rejection default "${db}" "unknown authority" + replace_postgres_ca default "${POSTGRES_TLS_DIR}/ca-cert.pem" + "${KUBECTL}" annotate "${db}" test.sql.crossplane.io/ca-rotation=restored --overwrite + "${KUBECTL}" wait "${db}" --for=condition=Synced=true --timeout=90s + echo_step_completed +} + +check_postgres_cluster_ca_config() { + echo_step "Checking PostgreSQL ClusterProviderConfig CA selector" + "${KUBECTL}" create namespace postgres-ca-trust + replace_postgres_ca postgres-ca-trust "${POSTGRES_TLS_DIR}/ca-cert.pem" + cat < provider-sql-ca-bundle.pem -kubectl -n crossplane-system create configmap provider-sql-ca \ - --from-file=ca-bundle.pem=provider-sql-ca-bundle.pem \ +kubectl -n crossplane-system create secret generic rds-ca \ + --from-file=ca-bundle.pem=rds-global-bundle.pem \ --dry-run=client -o yaml | kubectl apply --server-side -f - -kubectl apply -f examples/aws-iam/deployment-runtime-config.yaml ``` -Use the namespace where the provider pod runs if it differs from -`crossplane-system`. The certificates are public; a ConfigMap is sufficient. -Server-side apply avoids the client-side last-applied annotation size limit for -large combined bundles. -The runtime configuration mounts this ConfigMap read-only at -`/etc/provider-sql/ca/ca-bundle.pem` and sets `SSL_CERT_FILE` to that path. -[Go uses this variable when loading system roots](https://pkg.go.dev/crypto/x509#SystemCertPool). -No changes to the container image or PostgreSQL ProviderConfig API are needed. +For a cluster-scoped PostgreSQL ProviderConfig: -Attach the runtime configuration to your **existing** package Provider (adjust -`provider-sql` to its installed name): - -```sh -kubectl patch providers.pkg.crossplane.io provider-sql --type=merge \ - -p '{"spec":{"runtimeConfigRef":{"name":"provider-sql-aws-iam"}}}' +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem ``` -Each Provider has one runtimeConfigRef. If you already use a DeploymentRuntimeConfig -for workload identity, scheduling or other settings, merge this example's env, -volume and volumeMount into that configuration instead of replacing its reference. -Keep your existing IRSA or Pod Identity setup. The container name must be -`package-runtime` so Crossplane merges these settings into the provider container. -See [Crossplane runtime configuration](https://docs.crossplane.io/latest/packages/providers/#runtime-configuration). - -Then select `sslMode: verify-full` for PostgreSQL or `tls: "true"` for MySQL, as -shown in the cluster and namespaced IAM examples. Use the actual RDS endpoint -hostname, which must match the server certificate. A missing or untrusted CA -must cause a verified connection to fail; the provider does not silently disable -verification. +For a namespaced PostgreSQL ProviderConfig, create the CA Secret in that +ProviderConfig's namespace and omit `namespace` from the selector: -Maintain the bundle as public roots and RDS CAs rotate. After updating its -ConfigMap, restart the provider Deployment so Go reloads the trust roots: - -```sh -PROVIDER_DEPLOYMENT=your-provider-deployment-name -kubectl -n crossplane-system rollout restart deployment/"$PROVIDER_DEPLOYMENT" -kubectl -n crossplane-system rollout status deployment/"$PROVIDER_DEPLOYMENT" +```yaml +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + key: ca-bundle.pem ``` -[trust-manager](https://cert-manager.io/docs/trust/trust-manager/) can automate -bundle distribution: combine `useDefaultCAs: true` with an RDS CA source and -publish the result to a ConfigMap named `provider-sql-ca`, key `ca-bundle.pem`, -in the provider's namespace. It does **not** mount the bundle or update a running -process's trust store. The same runtime configuration and restart steps still -apply. Keep the RDS source current; trust-manager does not fetch the AWS URL for -you. - -### MySQL: use a CA Secret for one ProviderConfig - -This alternative keeps database CA trust separate from the pod's public roots. -It does not require `SSL_CERT_FILE` or a runtime mount. Create the CA Secret in -the namespace referenced by `tlsConfig`: - -```sh -kubectl -n crossplane-system create secret generic rds-ca \ - --from-file=ca-bundle.pem=rds-global-bundle.pem \ - --dry-run=client -o yaml | kubectl apply --server-side -f - -``` +ClusterProviderConfigs use an explicit Secret namespace, as in the first example. +The same CA selector works with password authentication. PostgreSQL CA selectors +require `verify-ca` or `verify-full`; other modes are rejected when a selector is +present. `verify-ca` verifies the certificate chain; `verify-full` also verifies +the endpoint hostname. -For a namespaced ProviderConfig, create the CA Secret in that config's namespace -and use the same namespace in `tlsConfig.caCert.secretRef` (the complete namespaced -example uses `default`). Existing TLS Secret selectors accept cross-namespace -references; they do not enforce tenant isolation. Restrict ProviderConfig writes -accordingly, especially when using client certificate and key Secrets. +Use `verify-full` for IAM authentication with the actual RDS endpoint hostname. +With `verify-ca`, a broad trust bundle can accept a certificate for a different +server and expose the IAM token. Keep the selected bundle limited to RDS CAs for +RDS connections. See [PostgreSQL's certificate verification guidance](https://www.postgresql.org/docs/current/libpq-ssl.html). -Then configure: +For MySQL, select `tls: custom` and provide the CA Secret: ```yaml spec: @@ -198,12 +141,27 @@ spec: key: ca-bundle.pem ``` -Complete examples are in -`examples/{cluster,namespaced}/mysql/config_iam_custom_ca.yaml`. -Keep `insecureSkipVerify` at its default `false`. Update the Secret when the -RDS CA changes; the next reconcile loads the updated bundle. Each ProviderConfig -has its own registered TLS configuration, including when namespaced configs -share a name. +MySQL's existing selectors require an explicit namespace. For a namespaced +ProviderConfig, use a Secret in its own namespace; existing MySQL selectors still +allow cross-namespace references and do not enforce tenant isolation. Restrict +ProviderConfig writes with RBAC. If mutual TLS is needed, provide both MySQL +`clientCert` and `clientKey`; an incomplete pair is rejected. + +Maintain the Secret as RDS CAs rotate. The provider reloads the selected bundle +on each reconcile, so updates take effect on subsequent connections without a +provider restart. [trust-manager](https://cert-manager.io/docs/trust/trust-manager/) +can distribute the bundle to Secrets; configure the selector to read its output. +The provider does not download or embed the RDS CA. + +Without a CA selector, existing driver trust-store behavior is preserved. +PostgreSQL's API defaults an omitted `sslMode` to `verify-full`; IAM preserves +`require`, `verify-ca` and `verify-full`, and upgrades `disable` to `require`. +`require` only encrypts unless lib/pq finds an explicit root certificate file; +loading system roots through `SSL_CERT_FILE` alone does not enable verification +in that mode. MySQL IAM upgrades omitted or `preferred` TLS to verified `true`; +that mode requires the CA in the driver's system pool. Explicit `skip-verify` +remains an operator choice. Kubernetes rejects explicit empty PostgreSQL modes +and MySQL empty or `false` modes. ## Region resolution diff --git a/examples/aws-iam/deployment-runtime-config.yaml b/examples/aws-iam/deployment-runtime-config.yaml deleted file mode 100644 index da55dd1d..00000000 --- a/examples/aws-iam/deployment-runtime-config.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# First create provider-sql-ca in the provider pod's namespace; see docs/aws-iam-auth.md. -# Merge into an existing runtime config when preserving workload identity settings. -apiVersion: pkg.crossplane.io/v1beta1 -kind: DeploymentRuntimeConfig -metadata: - name: provider-sql-aws-iam -spec: - deploymentTemplate: - spec: - selector: {} - template: - spec: - containers: - - name: package-runtime - env: - - name: SSL_CERT_FILE - value: /etc/provider-sql/ca/ca-bundle.pem - volumeMounts: - - name: database-ca - mountPath: /etc/provider-sql/ca - readOnly: true - volumes: - - name: database-ca - configMap: - name: provider-sql-ca - items: - - key: ca-bundle.pem - path: ca-bundle.pem diff --git a/examples/cluster/mysql/config_iam.yaml b/examples/cluster/mysql/config_iam.yaml index c215a89c..8886c8bb 100644 --- a/examples/cluster/mysql/config_iam.yaml +++ b/examples/cluster/mysql/config_iam.yaml @@ -10,10 +10,15 @@ spec: namespace: crossplane-system name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # Verify the server certificate using the mounted CA bundle. - # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. - tls: "true" ---- + # Verify the server using the CA Secret; no client certificate or pod mount is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem + # The connection secret holds only endpoint, port and username - no password. # The provider generates a short-lived IAM token and uses it as the password. # apiVersion: v1 diff --git a/examples/cluster/postgresql/config_iam.yaml b/examples/cluster/postgresql/config_iam.yaml index c689dde8..a56e4522 100644 --- a/examples/cluster/postgresql/config_iam.yaml +++ b/examples/cluster/postgresql/config_iam.yaml @@ -11,10 +11,15 @@ spec: namespace: crossplane-system name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # Verify the server certificate and hostname using the mounted CA bundle. - # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. + # Verify the server using the CA Secret; no pod trust mount is needed. sslMode: verify-full ---- + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem + # The connection secret holds only endpoint, port and username - no password. # The provider generates a short-lived IAM token and uses it as the password. # apiVersion: v1 diff --git a/examples/namespaced/mysql/config_iam.yaml b/examples/namespaced/mysql/config_iam.yaml index 1fbec824..f307c91b 100644 --- a/examples/namespaced/mysql/config_iam.yaml +++ b/examples/namespaced/mysql/config_iam.yaml @@ -10,6 +10,11 @@ spec: connectionSecretRef: name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # Verify the server certificate using the mounted CA bundle. - # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. - tls: "true" + # Verify the server using the CA Secret; no client certificate or pod mount is needed. + tls: custom + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: default + key: ca-bundle.pem diff --git a/examples/namespaced/postgresql/clusterconfig_iam.yaml b/examples/namespaced/postgresql/clusterconfig_iam.yaml new file mode 100644 index 00000000..f046c7e3 --- /dev/null +++ b/examples/namespaced/postgresql/clusterconfig_iam.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: ClusterProviderConfig +metadata: + name: aurora-iam +spec: + credentials: + source: AWSIAMAuth + connectionSecretRef: + name: aurora-conn + namespace: crossplane-system + sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + namespace: crossplane-system + key: ca-bundle.pem diff --git a/examples/namespaced/postgresql/config_iam.yaml b/examples/namespaced/postgresql/config_iam.yaml index 0483b3de..883cf51c 100644 --- a/examples/namespaced/postgresql/config_iam.yaml +++ b/examples/namespaced/postgresql/config_iam.yaml @@ -11,6 +11,10 @@ spec: connectionSecretRef: name: aurora-conn # endpoint, port, username - no password # region: us-east-1 # optional; otherwise a "region" secret key, then the controller's AWS env - # Verify the server certificate and hostname using the mounted CA bundle. - # See docs/aws-iam-auth.md and examples/aws-iam/deployment-runtime-config.yaml. + # Verify the server using the CA Secret; no pod trust mount is needed. sslMode: verify-full + tlsConfig: + caCert: + secretRef: + name: rds-ca + key: ca-bundle.pem diff --git a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml index 255e7d91..cddf0bc7 100644 --- a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml @@ -125,6 +125,39 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. Namespace, name + and key are required. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object diff --git a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml index 4db7e73c..c3bf57e0 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml @@ -126,6 +126,39 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. Namespace, name + and key are required. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object diff --git a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml index 286b1460..97a1097e 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml @@ -121,6 +121,34 @@ spec: - verify-ca - verify-full type: string + tlsConfig: + description: |- + TLSConfig selects the CA bundle used to verify the PostgreSQL server. + When set, sslMode must be verify-ca or verify-full. + properties: + caCert: + description: CACert contains the PEM-encoded certificates trusted + for this connection. + properties: + secretRef: + description: SecretRef selects the CA bundle. The Secret must + be in the ProviderConfig namespace. + properties: + key: + type: string + name: + description: Name of the secret. + type: string + required: + - key + - name + type: object + required: + - secretRef + type: object + required: + - caCert + type: object required: - credentials type: object diff --git a/pkg/clients/postgresql/postgresql.go b/pkg/clients/postgresql/postgresql.go index b9f78e89..d43516fc 100644 --- a/pkg/clients/postgresql/postgresql.go +++ b/pkg/clients/postgresql/postgresql.go @@ -7,6 +7,7 @@ import ( "errors" "net" "net/url" + "strings" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" "github.com/lib/pq" @@ -39,7 +40,7 @@ func New(creds map[string][]byte, database, sslmode string) xsql.DB { port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) password := string(creds[xpv2.CredentialsSecretPasswordKey]) - dsn := DSN(username, password, endpoint, port, database, sslmode) + dsn := DSN(username, password, endpoint, port, database, connectionSSLMode(sslmode)) return postgresDB{ dsn: dsn, @@ -55,7 +56,7 @@ func NewWithPasswordProvider(creds map[string][]byte, database, sslmode string, db.passwordProvider = password if password != nil { // Validate connection settings without including any credential in parser errors. - db.dsn = DSN(string(creds[xpv2.CredentialsSecretUserKey]), "", db.endpoint, db.port, database, sslmode) + db.dsn = DSN(string(creds[xpv2.CredentialsSecretUserKey]), "", db.endpoint, db.port, database, connectionSSLMode(sslmode)) } return db } @@ -65,6 +66,12 @@ func (c postgresDB) open() (*sql.DB, error) { if err != nil { return nil, withoutConnectionURL(err) } + if strings.HasPrefix(c.sslmode, "pqgo-provider-sql-postgresql-") { + // Apply the selected CA after parsing environment defaults. In particular, + // PGSSLROOTCERT must not replace or supplement a ProviderConfig's roots. + cfg.SSLMode = pq.SSLMode(c.sslmode) + cfg.SSLRootCert = "" + } if c.passwordProvider == nil { connector, err := pq.NewConnectorConfig(cfg) if err != nil { @@ -83,6 +90,16 @@ func (c postgresDB) open() (*sql.DB, error) { }), nil } +func connectionSSLMode(mode string) string { + if strings.HasPrefix(mode, "pqgo-provider-sql-postgresql-") { + // Parse with a standard verifying mode before applying the registered + // CA config. This also accepts PGSSLROOTCERT=system without allowing that + // process-wide setting to override the selected Secret. + return "verify-full" + } + return mode +} + // URL parser errors quote the complete DSN, including its password. func withoutConnectionURL(err error) error { var parseErr *url.Error diff --git a/pkg/clients/postgresql/tls.go b/pkg/clients/postgresql/tls.go new file mode 100644 index 00000000..8f23a0d1 --- /dev/null +++ b/pkg/clients/postgresql/tls.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postgresql + +import ( + "crypto/tls" + "crypto/x509" + "fmt" +) + +// TLSConfig builds server verification using only the selected CA bundle. +// verify-full checks both the certificate chain and the endpoint hostname. +// verify-ca checks the chain without checking the hostname. +func TLSConfig(mode, endpoint string, ca []byte) (*tls.Config, error) { + if mode != "verify-ca" && mode != "verify-full" { + return nil, fmt.Errorf("tlsConfig.caCert requires sslMode verify-ca or verify-full, got %q", mode) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("tlsConfig.caCert does not contain a valid PEM certificate") + } + cfg := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: endpoint, RootCAs: roots} + if mode == "verify-ca" { + cfg.InsecureSkipVerify = true //nolint:gosec // The callback verifies the chain; this mode deliberately omits hostname verification. + cfg.VerifyConnection = func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("PostgreSQL server presented no certificate") + } + intermediates := x509.NewCertPool() + for _, cert := range state.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + _, err := state.PeerCertificates[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates}) + return err + } + } + return cfg, nil +} diff --git a/pkg/clients/postgresql/tls_test.go b/pkg/clients/postgresql/tls_test.go index 53528de2..e0b3b3cb 100644 --- a/pkg/clients/postgresql/tls_test.go +++ b/pkg/clients/postgresql/tls_test.go @@ -22,6 +22,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" ) func TestIAMRequiresTLSWhenServerRefusesIt(t *testing.T) { @@ -103,22 +104,60 @@ func TestTLSSystemRoots(t *testing.T) { if err := os.WriteFile(os.Getenv("SSL_CERT_FILE"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { t.Fatal(err) } + secretKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + secretCA := *ca + secretCA.SerialNumber = big.NewInt(10) + secretCA.Subject = pkix.Name{CommonName: "separate Secret CA"} + secretDER, err := x509.CreateCertificate(rand.Reader, &secretCA, &secretCA, &secretKey.PublicKey, secretKey) + if err != nil { + t.Fatal(err) + } for _, tc := range []struct { - name, mode string - trusted, matching bool - want string - rootFile bool + name, mode string + trusted, matching bool + want string + rootFile, secretCA, systemRoots, systemServer bool + homeRoot, invalidHomeRoot bool }{ - {"trusted matching", "verify-full", true, true, "TLS accepted", false}, - {"untrusted", "verify-full", false, true, "unknown authority", false}, - {"wrong hostname", "verify-full", true, false, "not 127.0.0.1", false}, - {"CA only permits hostname mismatch", "verify-ca", true, false, "TLS accepted", false}, - {"require ignores system roots", "require", false, false, "TLS accepted", false}, - {"verify-ca rejects untrusted", "verify-ca", false, true, "unknown authority", false}, - {"require with root file verifies CA", "require", false, true, "unknown authority", true}, - {"require with root file skips hostname", "require", true, false, "TLS accepted", true}, + {name: "trusted matching", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted"}, + {name: "untrusted", mode: "verify-full", matching: true, want: "unknown authority"}, + {name: "wrong hostname", mode: "verify-full", trusted: true, want: "not 127.0.0.1"}, + {name: "CA only permits hostname mismatch", mode: "verify-ca", trusted: true, want: "TLS accepted"}, + {name: "require ignores system roots", mode: "require", want: "TLS accepted"}, + {name: "verify-ca rejects untrusted", mode: "verify-ca", matching: true, want: "unknown authority"}, + {name: "require with root file verifies CA", mode: "require", matching: true, want: "unknown authority", rootFile: true}, + {name: "require with root file skips hostname", mode: "require", trusted: true, want: "TLS accepted", rootFile: true}, + {name: "secret CA verifies server absent from system pool", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true}, + {name: "secret CA overrides system roots", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, systemRoots: true}, + {name: "secret CA rejects system-trusted server with explicit system roots", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemRoots: true, systemServer: true}, + {name: "secret CA rejects system-trusted server", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemServer: true}, + {name: "secret CA ignores unrelated default root file", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, homeRoot: true}, + {name: "secret CA ignores malformed default root file", mode: "verify-full", trusted: true, matching: true, want: "TLS accepted", secretCA: true, homeRoot: true, invalidHomeRoot: true}, + {name: "secret CA rejects server trusted only by default root file", mode: "verify-full", trusted: true, matching: true, want: "unknown authority", secretCA: true, homeRoot: true, systemServer: true}, + {name: "secret CA rejects unknown root", mode: "verify-full", matching: true, want: "unknown authority", secretCA: true}, + {name: "secret CA rejects wrong hostname", mode: "verify-full", trusted: true, want: "not 127.0.0.1", secretCA: true}, + {name: "secret verify-ca permits different hostname", mode: "verify-ca", trusted: true, want: "TLS accepted", secretCA: true}, + {name: "secret verify-ca rejects unknown root", mode: "verify-ca", matching: true, want: "unknown authority", secretCA: true}, + {name: "secret verify-ca rejects system-trusted server", mode: "verify-ca", trusted: true, matching: true, want: "unknown authority", secretCA: true, systemServer: true}, } { t.Run(tc.name, func(t *testing.T) { + if tc.homeRoot { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.Mkdir(filepath.Join(home, ".postgresql"), 0700); err != nil { + t.Fatal(err) + } + root := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + if tc.invalidHomeRoot { + root = []byte("invalid PEM") + } + if err := os.WriteFile(filepath.Join(home, ".postgresql", "root.crt"), root, 0600); err != nil { + t.Fatal(err) + } + } if tc.rootFile { t.Setenv("PGSSLROOTCERT", os.Getenv("SSL_CERT_FILE")) } @@ -132,6 +171,9 @@ func TestTLSSystemRoots(t *testing.T) { } leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP(ip)}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature} parent, signer := ca, caKey + if tc.secretCA && tc.trusted && !tc.systemServer { + parent, signer = &secretCA, secretKey + } if !tc.trusted { parent, signer = leaf, key } @@ -211,8 +253,29 @@ func TestTLSSystemRoots(t *testing.T) { if err != nil { t.Fatal(err) } + mode := EnsureTLS(tc.mode) + if tc.secretCA { + // A per-config CA must win over process-wide root settings. + t.Setenv("PGSSLROOTCERT", filepath.Join(dir, "does-not-exist.pem")) + if tc.systemRoots { + t.Setenv("PGSSLROOTCERT", "system") + } + if tc.homeRoot { + t.Setenv("PGSSLROOTCERT", "") + } + cfg, err := TLSConfig(tc.mode, host, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: secretDER})) + if err != nil { + t.Fatal(err) + } + key := "provider-sql-postgresql-" + strings.ReplaceAll(tc.name, " ", "-") + if err := pq.RegisterTLSConfig(key, cfg); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(key, nil) }) + mode = "pqgo-" + key + } signed := 0 - db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", EnsureTLS(tc.mode), func(context.Context) (string, error) { signed++; return token, nil }) + db := NewWithPasswordProvider(map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte(host), xpv2.CredentialsSecretPortKey: []byte(port), xpv2.CredentialsSecretUserKey: []byte("test")}, "postgres", mode, func(context.Context) (string, error) { signed++; return token, nil }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var value int diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index d850cebf..e4f34161 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -45,6 +45,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -149,6 +150,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed // Require encryption while preserving operator-configured verification. sslMode = postgresql.EnsureTLS(sslMode) } + + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil } diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 3b3df074..9af7c086 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -212,6 +212,7 @@ func TestConnect(t *testing.T) { }, }, args: args{ + ctx: context.Background(), mg: &v1alpha1.Database{ Spec: v1alpha1.DatabaseSpec{ ClusterManagedResourceSpec: xpv2.ClusterManagedResourceSpec{ diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index 27a4f01b..dde7c87e 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -43,6 +43,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -151,6 +152,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) } sslMode = postgresql.EnsureTLS(sslMode) } + + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + return &external{db: c.newDB(secretData, database, sslMode, password)}, nil } diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index c998641a..74819c1f 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -41,6 +41,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -139,6 +140,15 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage sslMode = postgresql.EnsureTLS(sslMode) } + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + // 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 { diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index 20e7587d..906d1d4c 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -44,6 +44,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -145,6 +146,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty } sslMode = postgresql.EnsureTLS(sslMode) } + + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode, password) // A server that cannot report its version is not a reason to fail: only diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index b2cc64df..454f7a2b 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -48,6 +48,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -151,8 +152,18 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ } sslMode = postgresql.EnsureTLS(sslMode) } + + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + return &external{ - db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password), + db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 1c01f7a4..960a0e65 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -43,6 +43,7 @@ import ( "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) const ( @@ -146,6 +147,16 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T } sslMode = postgresql.EnsureTLS(sslMode) } + + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + } + return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil } diff --git a/pkg/controller/namespaced/postgresql/provider/ca_test.go b/pkg/controller/namespaced/postgresql/provider/ca_test.go new file mode 100644 index 00000000..e8e61222 --- /dev/null +++ b/pkg/controller/namespaced/postgresql/provider/ca_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestCASecretNamespaces(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + bundle := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + original := newIAMToken + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "fresh-token", nil }, nil + } + t.Cleanup(func() { newIAMToken = original }) + modes := map[string]bool{} + for _, kind := range []string{v1alpha1.ProviderConfigKind, v1alpha1.ClusterProviderConfigKind} { + for _, namespace := range []string{"one", "two"} { + for _, iam := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/IAM=%t", kind, namespace, iam), func(t *testing.T) { + source := v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + source = v1alpha1.CredentialsSourceAWSIAMAuth + } + caNamespace := namespace + if kind == v1alpha1.ClusterProviderConfigKind { + caNamespace = "trust" + } + selected := false + mode := "verify-full" + kube := &test.MockClient{MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.ProviderConfig: + o.Spec.SSLMode = &mode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef.Name = "connection" + o.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.LocalSecretKeySelector{LocalSecretReference: xpv2.LocalSecretReference{Name: "ca"}, Key: "bundle"}}} + case *v1alpha1.ClusterProviderConfig: + o.Spec.SSLMode = &mode + o.Spec.Credentials.Source = source + o.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "connection", Namespace: "credentials"} + o.Spec.TLSConfig = &v1alpha1.ClusterTLSConfig{CACert: v1alpha1.ClusterTLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + case *corev1.Secret: + if key.Name == "ca" { + if key.Namespace != caNamespace { + return fmt.Errorf("unexpected CA namespace %s", key.Namespace) + } + selected = true + o.Data = map[string][]byte{"bundle": bundle} + } else { + o.Data = map[string][]byte{"endpoint": []byte("db.example"), "port": []byte("5432"), "username": []byte("user"), "password": []byte("static-password")} + } + } + return nil + }} + mg := &v1alpha1.Database{ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: kind, Name: "same"}}}} + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + if !selected || info.SSLMode == nil || !strings.HasPrefix(*info.SSLMode, "pqgo-provider-sql-postgresql-") { + t.Fatalf("CA was not selected: %+v", info) + } + if (info.PasswordProvider != nil) != iam { + t.Fatal("CA selector changed authentication source") + } + if string(info.SecretData["password"]) != "static-password" { + t.Fatal("CA selection mutated password Secret") + } + if !iam { + identity := kind + "/" + namespace + if kind == v1alpha1.ClusterProviderConfigKind { + identity = kind + } + if kind == v1alpha1.ProviderConfigKind && modes[*info.SSLMode] { + t.Fatalf("namespace collision for %s", identity) + } + modes[*info.SSLMode] = true + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(*info.SSLMode, nil) }) + }) + } + } + } +} diff --git a/pkg/controller/namespaced/postgresql/provider/provider.go b/pkg/controller/namespaced/postgresql/provider/provider.go index e406b28b..47b299cf 100644 --- a/pkg/controller/namespaced/postgresql/provider/provider.go +++ b/pkg/controller/namespaced/postgresql/provider/provider.go @@ -7,6 +7,10 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "context" + "fmt" + + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients" @@ -38,6 +42,8 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode keyMapping map[string]string source v1alpha1.PostgreSQLConnectionSource region *string + caRef *xpv2.SecretKeySelector + configIdentity string ) switch mg.GetProviderConfigReference().Kind { @@ -63,6 +69,10 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() source = providerConfig.Spec.Credentials.Source region = providerConfig.Spec.Credentials.Region + configIdentity = "namespaced/ProviderConfig/" + providerConfig.Namespace + "/" + providerConfig.Name + if providerConfig.Spec.TLSConfig != nil { + caRef = providerConfig.Spec.TLSConfig.CACert.SecretRef.ToSecretKeySelector(providerConfig.Namespace) + } case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ ObjectMeta: metav1.ObjectMeta{ @@ -84,6 +94,10 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() source = clusterProviderConfig.Spec.Credentials.Source region = clusterProviderConfig.Spec.Credentials.Region + configIdentity = "namespaced/ClusterProviderConfig/" + clusterProviderConfig.Name + if clusterProviderConfig.Spec.TLSConfig != nil { + caRef = &clusterProviderConfig.Spec.TLSConfig.CACert.SecretRef + } default: return ProviderInfo{}, provErrors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) } @@ -100,23 +114,30 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode secretData := xsql.RemapCredentialKeys(s.Data, keyMapping) - var password xsql.PasswordProvider + return configureConnection(ctx, kube, ProviderInfo{ + ProviderConfigName: mg.GetProviderConfigReference().Name, + SecretData: secretData, + DefaultDatabase: defaultDatabase, + SSLMode: sslMode, + }, source, region, configIdentity, caRef) +} + +func configureConnection(ctx context.Context, kube client.Client, info ProviderInfo, source v1alpha1.PostgreSQLConnectionSource, region *string, identity string, caRef *xpv2.SecretKeySelector) (ProviderInfo, error) { if source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, region, secretData) + password, err := newIAMToken(ctx, region, info.SecretData) if err != nil { return ProviderInfo{}, provErrors.GenerateIAMTokenError(err) } - // Require encryption while preserving operator-configured verification. - mode := postgresql.EnsureTLS(clients.ToString(sslMode)) - sslMode = &mode + info.PasswordProvider = password + mode := postgresql.EnsureTLS(clients.ToString(info.SSLMode)) + info.SSLMode = &mode } - - return ProviderInfo{ - PasswordProvider: password, - ProviderConfigName: mg.GetProviderConfigReference().Name, - SecretData: secretData, - DefaultDatabase: defaultDatabase, - SSLMode: sslMode, - }, nil + mode, err := postgresqltls.LoadConfig(ctx, kube, identity, clients.ToString(info.SSLMode), string(info.SecretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return ProviderInfo{}, fmt.Errorf("cannot configure PostgreSQL TLS: %w", err) + } + if caRef != nil { + info.SSLMode = &mode + } + return info, nil } diff --git a/pkg/controller/namespaced/postgresql/role/reconciler.go b/pkg/controller/namespaced/postgresql/role/reconciler.go index 1f4136f5..de6bb8fc 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler.go @@ -119,7 +119,7 @@ func (c *connector) Connect(ctx context.Context, mg *namespacedv1alpha1.Role) (m } return &external{ - db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider), + db: c.newDB(providerInfo.SecretData, providerInfo.DefaultDatabase, clients.ToString(providerInfo.SSLMode), providerInfo.PasswordProvider), kube: c.kube, apiReader: c.apiReader, }, nil diff --git a/pkg/controller/postgresql/tls/gc.go b/pkg/controller/postgresql/tls/gc.go new file mode 100644 index 00000000..0382b714 --- /dev/null +++ b/pkg/controller/postgresql/tls/gc.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "time" + + legacy "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/controller" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// Setup removes registrations for deleted configurations or removed selectors. +// A failed API list never removes entries. This runs with the controllers on +// the elected leader, whose lib/pq TLS registry is process-local. +func Setup(mgr ctrl.Manager, _ controller.Options) error { + return mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := defaultRegistry.collect(ctx, mgr.GetClient()); err != nil && ctx.Err() == nil { + mgr.GetLogger().Error(err, "cannot prune PostgreSQL TLS configurations") + } + } + } + })) +} + +func (r *tlsRegistry) collect(ctx context.Context, kube client.Client) error { + if err := r.lock(ctx); err != nil { + return err + } + empty := len(r.entries) == 0 + started := r.generation + r.unlock() + if empty { + return nil + } + // Skip entries loaded after collection started, so a stale list cannot retire + // a newly registered config that was created while these lists were running. + live, err := liveConfigurations(ctx, kube) + if err != nil { + return err + } + if err := r.lock(ctx); err != nil { + return err + } + defer r.unlock() + for identity, entry := range r.entries { + if !live[identity] && entry.generation <= started { + r.forget(identity) + } + } + return nil +} + +func liveConfigurations(ctx context.Context, kube client.Client) (map[string]bool, error) { + live := make(map[string]bool) + for _, list := range []client.ObjectList{&legacy.ProviderConfigList{}, &modern.ProviderConfigList{}, &modern.ClusterProviderConfigList{}} { + if err := kube.List(ctx, list); err != nil { + return nil, err + } + objects, err := meta.ExtractList(list) + if err != nil { + return nil, err + } + for _, object := range objects { + if identity, hasCA := configurationIdentity(object); hasCA { + live[identity] = true + } + } + } + return live, nil +} + +func configurationIdentity(object runtime.Object) (string, bool) { + switch pc := object.(type) { + case *legacy.ProviderConfig: + return "cluster/ProviderConfig/" + pc.Name, pc.Spec.TLSConfig != nil + case *modern.ProviderConfig: + return "namespaced/ProviderConfig/" + pc.Namespace + "/" + pc.Name, pc.Spec.TLSConfig != nil + case *modern.ClusterProviderConfig: + return "namespaced/ClusterProviderConfig/" + pc.Name, pc.Spec.TLSConfig != nil + default: + return "", false + } +} diff --git a/pkg/controller/postgresql/tls/lifecycle_test.go b/pkg/controller/postgresql/tls/lifecycle_test.go new file mode 100644 index 00000000..8cbb45d4 --- /dev/null +++ b/pkg/controller/postgresql/tls/lifecycle_test.go @@ -0,0 +1,330 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + legacy "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + modern "github.com/crossplane-contrib/provider-sql/apis/namespaced/postgresql/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func lifecycleClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, legacy.SchemeBuilder.AddToScheme, modern.SchemeBuilder.AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testCA(t)}}) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func lifecycleRef() *xpv2.SecretKeySelector { + return &xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"} +} + +func assertRegistered(t *testing.T, mode string, want bool) { + t.Helper() + _, err := pq.NewConfig("host=db.example sslmode=" + mode) + if (err == nil) != want { + t.Fatalf("registration %s: want registered=%t, got %v", mode, want, err) + } +} + +func TestRegistrationRetirement(t *testing.T) { + r := newTLSRegistry() + kube := lifecycleClient(t) + ctx := context.Background() + identity := "cluster/ProviderConfig/retirement" + first, err := r.load(ctx, kube, identity, "verify-full", "old.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + next, err := r.load(ctx, kube, identity, "verify-ca", "new.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + assertRegistered(t, first, false) + assertRegistered(t, next, true) + mode, err := r.load(ctx, kube, identity, "verify-full", "new.example", nil) + if err != nil || mode != "verify-full" { + t.Fatalf("removed selector: %q, %v", mode, err) + } + assertRegistered(t, next, false) + if len(r.entries) != 0 { + t.Fatal("retired registration retained") + } +} + +func TestCollectDeletedConfigurations(t *testing.T) { + objects := []client.Object{ + &legacy.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live"}, Spec: legacy.ProviderConfigSpec{TLSConfig: &legacy.TLSConfig{}}}, + &modern.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: "one"}, Spec: modern.ProviderConfigSpec{TLSConfig: &modern.TLSConfig{}}}, + &modern.ClusterProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "live"}, Spec: modern.ClusterProviderConfigSpec{TLSConfig: &modern.ClusterTLSConfig{}}}, + &legacy.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "removed-selector"}}, + } + kube := lifecycleClient(t, objects...) + r := newTLSRegistry() + ctx := context.Background() + modes := map[string]string{} + for _, id := range []string{"cluster/ProviderConfig/live", "namespaced/ProviderConfig/one/live", "namespaced/ClusterProviderConfig/live", "cluster/ProviderConfig/deleted", "cluster/ProviderConfig/removed-selector"} { + mode, err := r.load(ctx, kube, id, "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + modes[id] = mode + } + if err := r.collect(ctx, kube); err != nil { + t.Fatal(err) + } + for id, mode := range modes { + want := id == "cluster/ProviderConfig/live" || id == "namespaced/ProviderConfig/one/live" || id == "namespaced/ClusterProviderConfig/live" + assertRegistered(t, mode, want) + } + if len(r.entries) != 3 { + t.Fatalf("want three live configurations, got %d", len(r.entries)) + } + failed := &test.MockClient{MockList: func(context.Context, client.ObjectList, ...client.ListOption) error { + return errors.New("list unavailable") + }} + if err := r.collect(ctx, failed); err == nil { + t.Fatal("expected list failure") + } + if len(r.entries) != 3 { + t.Fatal("failed list removed live configurations") + } + for id := range r.entries { + r.forget(id) + } +} + +type waitingContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *waitingContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +func TestReloadWaiterCancellation(t *testing.T) { + r := newTLSRegistry() + if err := r.lock(context.Background()); err != nil { + t.Fatal(err) + } + defer r.unlock() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observed := &waitingContext{Context: ctx, waiting: make(chan struct{})} + done := make(chan error, 1) + go func() { _, err := r.load(observed, nil, "waiting", "verify-full", "db.example", nil); done <- err }() + select { + case <-observed.waiting: + case <-time.After(5 * time.Second): + t.Fatal("caller did not wait on registry lock") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("cancelled caller remained blocked") + } +} + +type blockedListClient struct { + client.Client + started, release chan struct{} + once sync.Once +} + +func (c *blockedListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + c.once.Do(func() { close(c.started); <-c.release }) + return c.Client.List(ctx, list, opts...) +} + +func TestCollectDoesNotRetireConcurrentRegistration(t *testing.T) { + r := newTLSRegistry() + kube := lifecycleClient(t) + ctx := context.Background() + old, err := r.load(ctx, kube, "cluster/ProviderConfig/old", "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + blocked := &blockedListClient{Client: kube, started: make(chan struct{}), release: make(chan struct{})} + done := make(chan error, 1) + var release sync.Once + defer release.Do(func() { close(blocked.release) }) + go func() { done <- r.collect(ctx, blocked) }() + select { + case <-blocked.started: + case <-time.After(5 * time.Second): + t.Fatal("collector did not start listing") + } + fresh, err := r.load(ctx, kube, "cluster/ProviderConfig/fresh", "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + release.Do(func() { close(blocked.release) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("collector did not finish") + } + assertRegistered(t, old, false) + assertRegistered(t, fresh, true) + if err := r.collect(ctx, kube); err != nil { + t.Fatal(err) + } + assertRegistered(t, fresh, false) +} + +func TestSecretReadsSerialized(t *testing.T) { + r := newTLSRegistry() + firstCA, secondCA := testCA(t), testCA(t) + started, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var reads atomic.Int32 + kube := &test.MockClient{MockGet: func(_ context.Context, _ client.ObjectKey, object client.Object) error { + ca := secondCA + if reads.Add(1) == 1 { + close(started) + <-release + ca = firstCA + } + object.(*corev1.Secret).Data = map[string][]byte{"bundle": ca} + return nil + }} + type result struct { + mode string + err error + } + results := make(chan result, 2) + load := func(ctx context.Context) { + mode, err := r.load(ctx, kube, "cluster/ProviderConfig/serialized", "verify-full", "db.example", lifecycleRef()) + results <- result{mode, err} + } + go load(context.Background()) + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first read did not start") + } + second := &waitingContext{Context: context.Background(), waiting: make(chan struct{})} + go load(second) + select { + case <-second.waiting: + case <-time.After(5 * time.Second): + t.Fatal("second reader did not reach lock") + } + if reads.Load() != 1 { + t.Fatal("second Secret read overlapped the first registration") + } + releaseOnce.Do(func() { close(release) }) + var previous string + for range 2 { + select { + case result := <-results: + if result.err != nil { + t.Fatal(result.err) + } + if previous != "" && result.mode != previous { + t.Fatal("CA rotation changed registration identity") + } + previous = result.mode + case <-time.After(5 * time.Second): + t.Fatal("serialized reload did not complete") + } + } + if reads.Load() != 2 { + t.Fatalf("want two ordered reads, got %d", reads.Load()) + } + r.forget("cluster/ProviderConfig/serialized") +} + +func TestSelectorChangesFailClosed(t *testing.T) { + for _, field := range []string{"namespace", "name", "key"} { + t.Run(field, func(t *testing.T) { + r := newTLSRegistry() + ref := lifecycleRef() + switch field { + case "namespace": + ref.Namespace = "other-trust" + case "name": + ref.Name = "other-ca" + case "key": + ref.Key = "other-bundle" + } + kube := lifecycleClient(t) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: ref.Name, Namespace: ref.Namespace}, Data: map[string][]byte{ref.Key: testCA(t)}} + if field == "key" { + if err := kube.Get(context.Background(), client.ObjectKeyFromObject(secret), secret); err != nil { + t.Fatal(err) + } + secret.Data[ref.Key] = testCA(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + } else if err := kube.Create(context.Background(), secret); err != nil { + t.Fatal(err) + } + identity := "cluster/ProviderConfig/selector-" + field + old, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", lifecycleRef()) + if err != nil { + t.Fatal(err) + } + next, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", ref) + if err != nil { + t.Fatal(err) + } + if next == old { + t.Fatal("different CA selectors reused the same trust registration") + } + assertRegistered(t, next, true) + // An in-flight reconcile with the previous selector must not replace + // the new selector's trust pool under an otherwise unchanged name. + if _, err := r.load(context.Background(), kube, identity, "verify-full", "db.example", lifecycleRef()); err != nil { + t.Fatal(err) + } + assertRegistered(t, next, false) + r.forget(identity) + }) + } +} diff --git a/pkg/controller/postgresql/tls/tls.go b/pkg/controller/postgresql/tls/tls.go new file mode 100644 index 00000000..fd6624ab --- /dev/null +++ b/pkg/controller/postgresql/tls/tls.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package tls loads PostgreSQL server CA bundles from ProviderConfig Secrets. +package tls + +import ( + "context" + "crypto/sha256" + "fmt" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" +) + +type registration struct { + name string + generation uint64 +} + +type tlsRegistry struct { + gate chan struct{} + entries map[string]registration + generation uint64 +} + +func newTLSRegistry() *tlsRegistry { + return &tlsRegistry{gate: make(chan struct{}, 1), entries: make(map[string]registration)} +} + +var defaultRegistry = newTLSRegistry() + +func (r *tlsRegistry) lock(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + select { + case r.gate <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *tlsRegistry) unlock() { <-r.gate } + +// forget is called while holding the registry lock. +func (r *tlsRegistry) forget(identity string) { + if old, ok := r.entries[identity]; ok { + _ = pq.RegisterTLSConfig(old.name, nil) + delete(r.entries, identity) + } +} + +// LoadConfig reads and registers the CA on each reconcile, so Secret rotation +// takes effect without restarting the provider. Identity must include the API +// scope, ProviderConfig kind, namespace and name to keep trust pools separate. +// Without a selector, the driver's existing trust-store behavior is preserved. +func LoadConfig(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + return defaultRegistry.load(ctx, kube, identity, mode, endpoint, ref) +} + +func (r *tlsRegistry) load(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + // Serialize the complete read/parse/register sequence. Otherwise an older + // Secret read could overwrite a newer bundle from a concurrent reconcile. + if err := r.lock(ctx); err != nil { + return "", err + } + defer r.unlock() + if ref == nil { + r.forget(identity) + return mode, nil + } + if mode == "" { + mode = "verify-full" + } + ca, err := readCA(ctx, kube, ref) + if err != nil { + return "", err + } + config, err := postgresql.TLSConfig(mode, endpoint, ca) + if err != nil { + return "", err + } + // Keep a stable registration per configuration, mode and endpoint. Replacing + // it refreshes this configuration's CA without changing another config's trust. + name := fmt.Sprintf("provider-sql-postgresql-%x", sha256.Sum256([]byte(identity+"\x00"+mode+"\x00"+endpoint+"\x00"+ref.Namespace+"\x00"+ref.Name+"\x00"+ref.Key))) + if err := pq.RegisterTLSConfig(name, config); err != nil { + return "", fmt.Errorf("cannot register PostgreSQL TLS configuration: %w", err) + } + if previous, ok := r.entries[identity]; ok && previous.name != name { + _ = pq.RegisterTLSConfig(previous.name, nil) + } + r.generation++ + r.entries[identity] = registration{name: name, generation: r.generation} + return "pqgo-" + name, nil +} + +func readCA(ctx context.Context, kube client.Client, ref *xpv2.SecretKeySelector) ([]byte, error) { + if ref.Name == "" || ref.Namespace == "" || ref.Key == "" { + return nil, fmt.Errorf("tlsConfig.caCert.secretRef requires name, namespace and key") + } + secret := &corev1.Secret{} + if err := kube.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, secret); err != nil { + return nil, fmt.Errorf("cannot get PostgreSQL CA Secret: %w", err) + } + ca, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("CA Secret %s/%s does not contain key %q", ref.Namespace, ref.Name, ref.Key) + } + return ca, nil +} diff --git a/pkg/controller/postgresql/tls/tls_test.go b/pkg/controller/postgresql/tls/tls_test.go new file mode 100644 index 00000000..deacd180 --- /dev/null +++ b/pkg/controller/postgresql/tls/tls_test.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "github.com/lib/pq" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func testCA(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestLoadConfig(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "one"}, Data: map[string][]byte{"bundle": testCA(t), "empty": {}, "bad": []byte("PRIVATE-MATERIAL-MUST-NOT-APPEAR")}}).Build() + ref := func(ns, name, key string) *xpv2.SecretKeySelector { + return &xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Namespace: ns, Name: name}, Key: key} + } + for _, tc := range []struct { + name, mode string + ref *xpv2.SecretKeySelector + want string + }{ + {"missing namespace", "verify-full", ref("", "ca", "bundle"), "requires name, namespace and key"}, + {"missing name", "verify-full", ref("one", "", "bundle"), "requires name, namespace and key"}, + {"missing key", "verify-full", ref("one", "ca", ""), "requires name, namespace and key"}, + {"other namespace cannot borrow secret", "verify-full", ref("two", "ca", "bundle"), "not found"}, + {"missing data key", "verify-full", ref("one", "ca", "absent"), "does not contain key"}, + {"empty PEM", "verify-full", ref("one", "ca", "empty"), "valid PEM certificate"}, + {"invalid PEM", "verify-full", ref("one", "ca", "bad"), "valid PEM certificate"}, + {"require rejected", "require", ref("one", "ca", "bundle"), "requires sslMode verify-ca or verify-full"}, + {"disabled rejected", "disable", ref("one", "ca", "bundle"), "requires sslMode verify-ca or verify-full"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadConfig(context.Background(), kube, "cluster/ProviderConfig/test", tc.mode, "db.example", tc.ref) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %q, got %v", tc.want, err) + } + if strings.Contains(err.Error(), "PRIVATE-MATERIAL") { + t.Fatal("error disclosed Secret contents") + } + }) + } + for _, mode := range []string{"", "require", "disable", "verify-ca", "verify-full"} { + got, err := LoadConfig(context.Background(), nil, "unused", mode, "db.example", nil) + if err != nil || got != mode { + t.Fatalf("omitted selector changed mode %q: %q, %v", mode, got, err) + } + } + modes := map[string]bool{} + for _, identity := range []string{"cluster/ProviderConfig/same", "namespaced/ClusterProviderConfig/same", "namespaced/ProviderConfig/one/same", "namespaced/ProviderConfig/two/same"} { + for _, mode := range []string{"verify-full", "verify-ca"} { + got, err := LoadConfig(context.Background(), kube, identity, mode, "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + if modes[got] { + t.Fatalf("registration collision for %s/%s", identity, mode) + } + modes[got] = true + if _, err := pq.NewConfig("host=db.example sslmode=" + got); err != nil { + t.Fatalf("registered mode rejected by real driver: %v", err) + } + t.Cleanup(func() { _ = pq.RegisterTLSConfig(got, nil) }) + } + } + // Rotation replaces a stable per-config registration rather than growing a + // new global registration on every reconcile or borrowing another config's CA. + identity := "namespaced/ProviderConfig/one/rotated" + before, err := LoadConfig(context.Background(), kube, identity, "verify-full", "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{} + if err := kube.Get(context.Background(), client.ObjectKey{Namespace: "one", Name: "ca"}, secret); err != nil { + t.Fatal(err) + } + secret.Data["bundle"] = testCA(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + after, err := LoadConfig(context.Background(), kube, identity, "verify-full", "db.example", ref("one", "ca", "bundle")) + if err != nil || after != before { + t.Fatalf("rotation: %q -> %q, %v", before, after, err) + } + _ = pq.RegisterTLSConfig(after, nil) + unset, err := LoadConfig(context.Background(), kube, "default-mode", "", "db.example", ref("one", "ca", "bundle")) + if err != nil { + t.Fatal(err) + } + _ = pq.RegisterTLSConfig(unset, nil) +} diff --git a/pkg/controller/sql.go b/pkg/controller/sql.go index 2f4c7d51..749ee866 100644 --- a/pkg/controller/sql.go +++ b/pkg/controller/sql.go @@ -27,6 +27,7 @@ import ( namespacedmssql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mssql" namespacedmysql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/mysql" namespacedpostgresql "github.com/crossplane-contrib/provider-sql/pkg/controller/namespaced/postgresql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" ) // Setup creates all controllers with the supplied logger and adds @@ -39,6 +40,7 @@ func Setup(mgr ctrl.Manager, l controller.Options) error { namespacedmssql.Setup, namespacedmysql.Setup, namespacedpostgresql.Setup, + postgresqltls.Setup, } { if err := setup(mgr, l); err != nil { return err From dd2c7e460458d155fd9dd724bd0131ce5ae2b473 Mon Sep 17 00:00:00 2001 From: michielvha Date: Fri, 30 Oct 2026 16:57:42 +0100 Subject: [PATCH 10/11] fix: address IAM connection and TLS lifecycle review Signed-off-by: michielvha --- .../postgresql/v1alpha1/provider_types.go | 1 + .../v1alpha1/cluster_provider_types.go | 1 + .../postgresql/v1alpha1/provider_types.go | 1 + cluster/local/postgresdb_functions.sh | 54 ++++++ docs/aws-iam-auth.md | 4 +- ...sql.sql.crossplane.io_providerconfigs.yaml | 4 + ....crossplane.io_clusterproviderconfigs.yaml | 4 + ...l.sql.m.crossplane.io_providerconfigs.yaml | 4 + pkg/clients/postgresql/postgresql.go | 32 ++-- .../cluster/mysql/connect/connect.go | 50 +++++ .../cluster/mysql/connect/connect_test.go | 175 ++++++++++++++++++ .../cluster/mysql/database/reconciler.go | 29 +-- .../cluster/mysql/database/reconciler_test.go | 19 +- .../cluster/mysql/grant/reconciler.go | 29 +-- .../cluster/mysql/user/reconciler.go | 29 +-- .../cluster/postgresql/connect/connect.go | 57 ++++++ .../postgresql/connect/connect_test.go | 171 +++++++++++++++++ .../cluster/postgresql/database/reconciler.go | 30 +-- .../postgresql/database/reconciler_test.go | 21 ++- .../default_privileges/reconciler.go | 29 +-- .../postgresql/extension/reconciler.go | 29 +-- .../cluster/postgresql/grant/reconciler.go | 29 +-- .../cluster/postgresql/role/reconciler.go | 29 +-- .../cluster/postgresql/schema/reconciler.go | 29 +-- .../namespaced/mysql/provider/provider.go | 18 +- .../mysql/provider/provider_test.go | 33 ++++ .../postgresql/tls/lifecycle_test.go | 82 +++++++- pkg/controller/postgresql/tls/tls.go | 50 ++++- 28 files changed, 769 insertions(+), 274 deletions(-) create mode 100644 pkg/controller/cluster/mysql/connect/connect.go create mode 100644 pkg/controller/cluster/mysql/connect/connect_test.go create mode 100644 pkg/controller/cluster/postgresql/connect/connect.go create mode 100644 pkg/controller/cluster/postgresql/connect/connect_test.go diff --git a/apis/cluster/postgresql/v1alpha1/provider_types.go b/apis/cluster/postgresql/v1alpha1/provider_types.go index 2bdd5bda..7807d6e5 100644 --- a/apis/cluster/postgresql/v1alpha1/provider_types.go +++ b/apis/cluster/postgresql/v1alpha1/provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ProviderConfigSpec defines the desired state of a ProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` diff --git a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go index 5e142d6d..0ab6d7c5 100644 --- a/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/cluster_provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ClusterProviderConfigSpec defines the desired state of a ClusterProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ClusterProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ClusterProviderCredentials `json:"credentials"` diff --git a/apis/namespaced/postgresql/v1alpha1/provider_types.go b/apis/namespaced/postgresql/v1alpha1/provider_types.go index 1913c9d9..cb6d63f3 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -23,6 +23,7 @@ import ( ) // A ProviderConfigSpec defines the desired state of a ProviderConfig. +// +kubebuilder:validation:XValidation:rule="!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in ['verify-ca', 'verify-full'])",message="tlsConfig requires sslMode verify-ca or verify-full" type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` diff --git a/cluster/local/postgresdb_functions.sh b/cluster/local/postgresdb_functions.sh index 2c1df4ec..a5e0c14a 100644 --- a/cluster/local/postgresdb_functions.sh +++ b/cluster/local/postgresdb_functions.sh @@ -649,6 +649,7 @@ integration_tests_postgres() { check_extension_test delete_extension_test if [[ "${TLS}" == "true" ]]; then + check_postgres_ca_admission check_postgres_ca_rotation check_postgres_verification_modes check_postgres_default_privileges_ca @@ -884,3 +885,56 @@ EOFPRIVILEGES "${KUBECTL}" delete "${resource}" --timeout=90s echo_step_completed } + +# Check admission on the generated CRDs, including defaulting before CEL validation. +check_postgres_ca_admission() { + echo_step "check PostgreSQL CA selector admission and sslMode defaulting" + local kind source sample mode has_ca selector_namespace manifest result rejected count=0 + local kinds="ProviderConfig" + if [[ "${API_TYPE}" == "namespaced" ]]; then + kinds="ProviderConfig ClusterProviderConfig" + fi + for kind in ${kinds}; do + selector_namespace=default + if [[ "${API_TYPE}" == "namespaced" && "${kind}" == "ProviderConfig" ]]; then + selector_namespace="" + fi + for source in PostgreSQLConnectionSecret AWSIAMAuth; do + for sample in omitted verify-ca verify-full require disable require-no-ca disable-no-ca; do + mode=${sample%-no-ca} + has_ca=true + rejected=false + if [[ "${sample}" == *-no-ca ]]; then + has_ca=false + elif [[ "${mode}" == "require" || "${mode}" == "disable" ]]; then + rejected=true + fi + manifest=$(jq -n \ + --arg api "postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/v1alpha1" \ + --arg kind "${kind}" --arg source "${source}" --arg mode "${mode}" \ + --arg ns "${selector_namespace}" --argjson ca "${has_ca}" ' + {apiVersion: $api, kind: $kind, metadata: {name: "e2e-ca-admission"}, + spec: {credentials: {source: $source, connectionSecretRef: {name: "unused"}}}} + | if $ns == "" then .metadata.namespace = "default" + else .spec.credentials.connectionSecretRef.namespace = $ns end + | if $mode == "omitted" then . else .spec.sslMode = $mode end + | if $ca then .spec.tlsConfig.caCert.secretRef = {name: "unused", key: "bundle"} + | if $ns == "" then . else .spec.tlsConfig.caCert.secretRef.namespace = $ns end + else . end') + if result=$(printf '%s\n' "${manifest}" | "${KUBECTL}" create --dry-run=server -f - -o json 2>&1); then + if [[ "${rejected}" == "true" ]]; then + echo_error "${kind} accepted ${source} with a CA and sslMode=${mode}" + fi + if [[ "${mode}" == "omitted" && $(jq -r '.spec.sslMode' <<<"${result}") != "verify-full" ]]; then + echo_error "${kind} did not default sslMode to verify-full" + fi + elif [[ "${rejected}" != "true" || "${result}" != *"tlsConfig requires sslMode verify-ca or verify-full"* ]]; then + echo_error "Unexpected PostgreSQL admission failure for ${kind}/${source}/${sample}: ${result}" + fi + count=$((count + 1)) + done + done + done + echo_info "${count} PostgreSQL admission cases passed" + echo_step_completed +} diff --git a/docs/aws-iam-auth.md b/docs/aws-iam-auth.md index b7377ffe..42effdbe 100644 --- a/docs/aws-iam-auth.md +++ b/docs/aws-iam-auth.md @@ -115,7 +115,9 @@ spec: ClusterProviderConfigs use an explicit Secret namespace, as in the first example. The same CA selector works with password authentication. PostgreSQL CA selectors require `verify-ca` or `verify-full`; other modes are rejected when a selector is -present. `verify-ca` verifies the certificate chain; `verify-full` also verifies +present. This also applies to IAM: `sslMode: require` with `tlsConfig` is invalid; +use `verify-full` (the default) or `verify-ca` instead. `verify-ca` verifies the +certificate chain; `verify-full` also verifies the endpoint hostname. Use `verify-full` for IAM authentication with the actual RDS endpoint hostname. diff --git a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml index cddf0bc7..41bfa61b 100644 --- a/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.crossplane.io_providerconfigs.yaml @@ -161,6 +161,10 @@ spec: required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ProviderConfigStatus reflects the observed state of a ProviderConfig. properties: diff --git a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml index c3bf57e0..91157834 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_clusterproviderconfigs.yaml @@ -162,6 +162,10 @@ spec: required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ClusterProviderConfigStatus reflects the observed state of a ClusterProviderConfig. diff --git a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml index 97a1097e..73cdf24c 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_providerconfigs.yaml @@ -152,6 +152,10 @@ spec: required: - credentials type: object + x-kubernetes-validations: + - message: tlsConfig requires sslMode verify-ca or verify-full + rule: '!has(self.tlsConfig) || (has(self.sslMode) && self.sslMode in + [''verify-ca'', ''verify-full''])' status: description: A ProviderConfigStatus reflects the observed state of a ProviderConfig. properties: diff --git a/pkg/clients/postgresql/postgresql.go b/pkg/clients/postgresql/postgresql.go index d43516fc..0d18e6c2 100644 --- a/pkg/clients/postgresql/postgresql.go +++ b/pkg/clients/postgresql/postgresql.go @@ -36,29 +36,27 @@ type postgresDB struct { // value of PGDATABASE, or if unset, the hardcoded string 'postgres'. // The sslmode defines the mode used to set up the connection for the provider. func New(creds map[string][]byte, database, sslmode string) xsql.DB { + return newPostgresDB(creds, database, sslmode, nil) +} + +// NewWithPasswordProvider configures credentials generated for each physical connection. +func NewWithPasswordProvider(creds map[string][]byte, database, sslmode string, password xsql.PasswordProvider) xsql.DB { + return newPostgresDB(creds, database, sslmode, password) +} + +func newPostgresDB(creds map[string][]byte, database, sslmode string, passwordProvider xsql.PasswordProvider) postgresDB { endpoint := string(creds[xpv2.CredentialsSecretEndpointKey]) port := string(creds[xpv2.CredentialsSecretPortKey]) username := string(creds[xpv2.CredentialsSecretUserKey]) password := string(creds[xpv2.CredentialsSecretPasswordKey]) - dsn := DSN(username, password, endpoint, port, database, connectionSSLMode(sslmode)) - - return postgresDB{ - dsn: dsn, - endpoint: endpoint, - port: port, - sslmode: sslmode, + if passwordProvider != nil { + // Parse connection settings without retaining a password in the DSN. + password = "" } -} - -// NewWithPasswordProvider configures credentials generated for each physical connection. -func NewWithPasswordProvider(creds map[string][]byte, database, sslmode string, password xsql.PasswordProvider) xsql.DB { - db := New(creds, database, sslmode).(postgresDB) - db.passwordProvider = password - if password != nil { - // Validate connection settings without including any credential in parser errors. - db.dsn = DSN(string(creds[xpv2.CredentialsSecretUserKey]), "", db.endpoint, db.port, database, connectionSSLMode(sslmode)) + return postgresDB{ + dsn: DSN(username, password, endpoint, port, database, connectionSSLMode(sslmode)), + endpoint: endpoint, port: port, sslmode: sslmode, passwordProvider: passwordProvider, } - return db } func (c postgresDB) open() (*sql.DB, error) { diff --git a/pkg/controller/cluster/mysql/connect/connect.go b/pkg/controller/cluster/mysql/connect/connect.go new file mode 100644 index 00000000..fde6544a --- /dev/null +++ b/pkg/controller/cluster/mysql/connect/connect.go @@ -0,0 +1,50 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package connect prepares MySQL connections for cluster reconcilers. +package connect + +import ( + "context" + "fmt" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var newIAMToken = awsiam.NewTokenProvider + +// Configure preserves password-auth defaults and prepares IAM signing and CA trust. +func Configure(ctx context.Context, kube client.Client, pc *v1alpha1.ProviderConfig, secretData map[string][]byte) (*string, bool, xsql.PasswordProvider, error) { + mode, err := tls.LoadConfig(ctx, kube, pc.Name, pc.Spec.TLS, pc.Spec.TLSConfig) + if err != nil { + return nil, false, nil, fmt.Errorf("cannot load TLS config: %w", err) + } + var password xsql.PasswordProvider + iam := pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth + if iam { + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { + return nil, false, nil, fmt.Errorf("cannot generate AWS IAM authentication token: %w", err) + } + mode = mysql.EnsureTLS(mode) + } + return mode, iam, password, nil +} diff --git a/pkg/controller/cluster/mysql/connect/connect_test.go b/pkg/controller/cluster/mysql/connect/connect_test.go new file mode 100644 index 00000000..7e16699d --- /dev/null +++ b/pkg/controller/cluster/mysql/connect/connect_test.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package connect + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + driver "github.com/go-sql-driver/mysql" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestConfigureModesAndAuthentication(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + calls := 0 + newIAMToken = func(_ context.Context, region *string, _ map[string][]byte) (xsql.PasswordProvider, error) { + calls++ + if region == nil || *region != "eu-central-1" { + t.Fatal("region was not passed through") + } + tokens := 0 + return func(context.Context) (string, error) { tokens++; return fmt.Sprintf("token-%d", tokens), nil }, nil + } + for _, iam := range []bool{false, true} { + for _, tc := range []struct{ mode, iamMode string }{{"unset", "true"}, {"", "true"}, {"false", "true"}, {"preferred", "true"}, {"true", "true"}, {"skip-verify", "skip-verify"}} { + t.Run(fmt.Sprintf("IAM=%t/%s", iam, tc.mode), func(t *testing.T) { + calls = 0 + region := "eu-central-1" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: strings.ReplaceAll(t.Name(), "/", "-")}} + pc.Spec.Credentials.Region = ®ion + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + if iam { + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + } + if tc.mode != "unset" { + pc.Spec.TLS = &tc.mode + } + creds := map[string][]byte{xpv2.CredentialsSecretPasswordKey: []byte("stored-password")} + mode, cleartext, password, err := Configure(context.Background(), nil, pc, creds) + if err != nil { + t.Fatal(err) + } + want := tc.mode + if want == "unset" { + want = "" + } + if iam { + want = tc.iamMode + } + got := "" + if mode != nil { + got = *mode + } + if cleartext != iam { + t.Fatal("cleartext flag does not match IAM") + } + if !iam && tc.mode == "unset" && mode != nil { + t.Fatal("password auth changed the nil TLS mode") + } + if got != want { + t.Fatalf("mode: got %q, want %q", got, want) + } + if iam { + if calls != 1 || password == nil { + t.Fatal("IAM factory was not used exactly once") + } + for i := 1; i <= 2; i++ { + token, err := password(context.Background()) + if err != nil || token != fmt.Sprintf("token-%d", i) { + t.Fatalf("fresh password: %q, %v", token, err) + } + } + } else if calls != 0 || password != nil { + t.Fatal("password authentication invoked IAM signing") + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != "stored-password" { + t.Fatal("configuration rewrote stored credentials") + } + }) + } + } +} + +func TestConfigureIAMError(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + boom := errors.New("signer unavailable") + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { return nil, boom } + pc := &v1alpha1.ProviderConfig{} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + _, _, _, err := Configure(context.Background(), nil, pc, nil) + if !errors.Is(err, boom) || !strings.Contains(err.Error(), "cannot generate AWS IAM authentication token") { + t.Fatalf("IAM error: %v", err) + } +} + +func testBundle(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestConfigureCAOnlyAndTLSFailure(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { + return func(context.Context) (string, error) { return "token", nil }, nil + } + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testBundle(t)}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + mode := "custom" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "helper-ca"}} + pc.Spec.TLS = &mode + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + pc.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + name, cleartext, password, err := Configure(context.Background(), kube, pc, nil) + if err != nil || name == nil || !cleartext || password == nil { + t.Fatalf("CA-only IAM config: %v", err) + } + t.Cleanup(func() { driver.DeregisterTLSConfig(*name) }) + next, _, _, err := Configure(context.Background(), kube, pc, nil) + if err != nil || *next != *name { + t.Fatalf("registration changed on reload: %v", err) + } + secret.Data["bundle"] = []byte("not a certificate") + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + if _, _, _, err := Configure(context.Background(), kube, pc, nil); err == nil || !strings.Contains(err.Error(), "cannot load TLS config") { + t.Fatalf("TLS error: %v", err) + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler.go b/pkg/controller/cluster/mysql/database/reconciler.go index f496b5b8..15eddf5a 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -36,10 +36,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -47,9 +46,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" - - errGenerateIAMToken = "cannot generate AWS IAM authentication token" errSelectDB = "cannot select database" errCreateDB = "cannot create database" @@ -94,10 +90,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -130,25 +122,12 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - - cleartext := false - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - cleartext = true - // Default IAM connections to verified TLS using the configured trust roots. - tlsName = mysql.EnsureTLS(tlsName) - } return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } diff --git a/pkg/controller/cluster/mysql/database/reconciler_test.go b/pkg/controller/cluster/mysql/database/reconciler_test.go index 2a61e1d3..e26346ca 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -19,6 +19,8 @@ package database import ( "context" "database/sql" + "os" + "strings" "testing" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" @@ -66,12 +68,14 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } - // Stub the AWS IAM token injection so the success case needs no AWS access. - origInject := newIAMToken - newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { - return func(context.Context) (string, error) { return "iam-token", nil }, nil + for key, value := range map[string]string{ + "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", + "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true", + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + } { + t.Setenv(key, value) } - defer func() { newIAMToken = origInject }() type fields struct { kube client.Client @@ -195,7 +199,7 @@ func TestConnect(t *testing.T) { if password == nil { t.Fatal("missing per-connection password provider") } - if got, err := password(context.Background()); err != nil || got != "iam-token" { + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { t.Errorf("token: %q, %v", got, err) } if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { @@ -223,6 +227,9 @@ func TestConnect(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &connector{kube: tc.fields.kube, track: tc.fields.track, newDB: tc.fields.newDB} + if tc.args.ctx == nil { + tc.args.ctx = context.Background() + } _, err := e.Connect(tc.args.ctx, tc.args.mg) if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) diff --git a/pkg/controller/cluster/mysql/grant/reconciler.go b/pkg/controller/cluster/mysql/grant/reconciler.go index d4ba1cf2..c559d6ad 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -40,10 +40,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -51,9 +50,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" - - errGenerateIAMToken = "cannot generate AWS IAM authentication token" errCreateGrant = "cannot create grant" errRevokeGrant = "cannot revoke grant" @@ -102,10 +98,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -138,25 +130,12 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - - cleartext := false - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - cleartext = true - // Default IAM connections to verified TLS using the configured trust roots. - tlsName = mysql.EnsureTLS(tlsName) - } return &external{db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password)}, nil } diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 7aec2d65..c80a391a 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -39,10 +39,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/mysql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/mysql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/mysql/connect" ) const ( @@ -50,9 +49,6 @@ const ( errGetPC = "cannot get ProviderConfig" errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errTLSConfig = "cannot load TLS config" - - errGenerateIAMToken = "cannot generate AWS IAM authentication token" errSelectUser = "cannot select user" errCreateUser = "cannot create user" @@ -97,10 +93,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -133,25 +125,12 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.User) (managed.Typ return nil, errors.Wrap(err, errGetSecret) } - tlsName, err := tls.LoadConfig(ctx, c.kube, providerConfigName, pc.Spec.TLS, pc.Spec.TLSConfig) + secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) + tlsName, cleartext, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, errTLSConfig) + return nil, err } - secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - - cleartext := false - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - cleartext = true - // Default IAM connections to verified TLS using the configured trust roots. - tlsName = mysql.EnsureTLS(tlsName) - } return &external{ db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext, password), kube: c.kube, diff --git a/pkg/controller/cluster/postgresql/connect/connect.go b/pkg/controller/cluster/postgresql/connect/connect.go new file mode 100644 index 00000000..25a06afb --- /dev/null +++ b/pkg/controller/cluster/postgresql/connect/connect.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package connect prepares PostgreSQL connections for cluster reconcilers. +package connect + +import ( + "context" + "fmt" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients" + "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" + "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var newIAMToken = awsiam.NewTokenProvider + +// Configure preserves password-auth defaults and prepares IAM signing and CA trust. +func Configure(ctx context.Context, kube client.Client, pc *v1alpha1.ProviderConfig, secretData map[string][]byte) (string, xsql.PasswordProvider, error) { + mode := clients.ToString(pc.Spec.SSLMode) + var password xsql.PasswordProvider + if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { + var err error + password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) + if err != nil { + return "", nil, fmt.Errorf("cannot generate AWS IAM authentication token: %w", err) + } + mode = postgresql.EnsureTLS(mode) + } + var caRef *xpv2.SecretKeySelector + if pc.Spec.TLSConfig != nil { + caRef = &pc.Spec.TLSConfig.CACert.SecretRef + } + mode, err := postgresqltls.LoadConfig(ctx, kube, "cluster/ProviderConfig/"+pc.Name, mode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + if err != nil { + return "", nil, fmt.Errorf("cannot configure PostgreSQL TLS: %w", err) + } + return mode, password, nil +} diff --git a/pkg/controller/cluster/postgresql/connect/connect_test.go b/pkg/controller/cluster/postgresql/connect/connect_test.go new file mode 100644 index 00000000..d67d79dc --- /dev/null +++ b/pkg/controller/cluster/postgresql/connect/connect_test.go @@ -0,0 +1,171 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package connect + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" + "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" + postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestConfigureModesAndAuthentication(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + calls := 0 + newIAMToken = func(_ context.Context, region *string, _ map[string][]byte) (xsql.PasswordProvider, error) { + calls++ + if region == nil || *region != "eu-central-1" { + t.Fatal("region was not passed through") + } + tokens := 0 + return func(context.Context) (string, error) { tokens++; return fmt.Sprintf("token-%d", tokens), nil }, nil + } + for _, iam := range []bool{false, true} { + for _, tc := range []struct{ mode, iamMode string }{{"unset", "require"}, {"", "require"}, {"disable", "require"}, {"allow", "require"}, {"prefer", "require"}, {"require", "require"}, {"verify-ca", "verify-ca"}, {"verify-full", "verify-full"}} { + t.Run(fmt.Sprintf("IAM=%t/%s", iam, tc.mode), func(t *testing.T) { + calls = 0 + region := "eu-central-1" + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: strings.ReplaceAll(t.Name(), "/", "-")}} + pc.Spec.Credentials.Region = ®ion + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + if iam { + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + } + if tc.mode != "unset" { + pc.Spec.SSLMode = &tc.mode + } + creds := map[string][]byte{xpv2.CredentialsSecretPasswordKey: []byte("stored-password")} + mode, password, err := Configure(context.Background(), nil, pc, creds) + if err != nil { + t.Fatal(err) + } + want := tc.mode + if want == "unset" { + want = "" + } + if iam { + want = tc.iamMode + } + got := mode + if got != want { + t.Fatalf("mode: got %q, want %q", got, want) + } + if iam { + if calls != 1 || password == nil { + t.Fatal("IAM factory was not used exactly once") + } + for i := 1; i <= 2; i++ { + token, err := password(context.Background()) + if err != nil || token != fmt.Sprintf("token-%d", i) { + t.Fatalf("fresh password: %q, %v", token, err) + } + } + } else if calls != 0 || password != nil { + t.Fatal("password authentication invoked IAM signing") + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != "stored-password" { + t.Fatal("configuration rewrote stored credentials") + } + }) + } + } +} + +func TestConfigureIAMError(t *testing.T) { + original := newIAMToken + t.Cleanup(func() { newIAMToken = original }) + boom := errors.New("signer unavailable") + newIAMToken = func(context.Context, *string, map[string][]byte) (xsql.PasswordProvider, error) { return nil, boom } + pc := &v1alpha1.ProviderConfig{} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceAWSIAMAuth + _, _, err := Configure(context.Background(), nil, pc, nil) + if !errors.Is(err, boom) || !strings.Contains(err.Error(), "cannot generate AWS IAM authentication token") { + t.Fatalf("IAM error: %v", err) + } +} + +func testBundle(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestConfigureCAReplacementAndValidation(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "trust"}, Data: map[string][]byte{"bundle": testBundle(t)}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + pc := &v1alpha1.ProviderConfig{ObjectMeta: metav1.ObjectMeta{Name: "helper-ca"}} + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourcePostgreSQLConnectionSecret + pc.Spec.TLSConfig = &v1alpha1.TLSConfig{CACert: v1alpha1.TLSSecret{SecretRef: xpv2.SecretKeySelector{SecretReference: xpv2.SecretReference{Name: "ca", Namespace: "trust"}, Key: "bundle"}}} + creds := map[string][]byte{xpv2.CredentialsSecretEndpointKey: []byte("db.example")} + first, password, err := Configure(context.Background(), kube, pc, creds) + if err != nil || password != nil || !strings.HasPrefix(first, "pqgo-provider-sql-postgresql-") { + t.Fatalf("CA-only config: %q %v", first, err) + } + t.Cleanup(func() { + _, _ = postgresqltls.LoadConfig(context.Background(), nil, "cluster/ProviderConfig/helper-ca", "verify-full", "db.example", nil) + }) + secret.Data["bundle"] = []byte("not a certificate") + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + if _, _, err := Configure(context.Background(), kube, pc, creds); err == nil { + t.Fatal("invalid CA was accepted") + } + secret.Data["bundle"] = testBundle(t) + if err := kube.Update(context.Background(), secret); err != nil { + t.Fatal(err) + } + next, _, err := Configure(context.Background(), kube, pc, creds) + if err != nil || next != first { + t.Fatalf("CA replacement changed identity: %q %v", next, err) + } + require := "require" + pc.Spec.SSLMode = &require + if _, _, err := Configure(context.Background(), kube, pc, creds); err == nil { + t.Fatal("CA with require mode was accepted") + } +} diff --git a/pkg/controller/cluster/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index e4f34161..0f845e36 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler.go +++ b/pkg/controller/cluster/postgresql/database/reconciler.go @@ -41,11 +41,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -54,8 +52,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errSelectDB = "cannot select database" errSelectServerVer = "cannot determine PostgreSQL server version" errCreateDB = "cannot create database" @@ -102,10 +98,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -139,25 +131,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Database) (managed secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - // Require encryption while preserving operator-configured verification. - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } return &external{db: c.newDB(secretData, pc.Spec.DefaultDatabase, sslMode, password)}, nil diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 9af7c086..412fe677 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -19,6 +19,8 @@ package database import ( "context" "database/sql" + "os" + "strings" "testing" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" @@ -73,12 +75,14 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } - // Stub the AWS IAM token injection so the success case needs no AWS access. - origInject := newIAMToken - newIAMToken = func(_ context.Context, _ *string, creds map[string][]byte) (xsql.PasswordProvider, error) { - return func(context.Context) (string, error) { return "iam-token", nil }, nil + for key, value := range map[string]string{ + "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", + "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true", + "AWS_CONFIG_FILE": os.DevNull, "AWS_SHARED_CREDENTIALS_FILE": os.DevNull, + "AWS_PROFILE": "", "AWS_DEFAULT_PROFILE": "", + } { + t.Setenv(key, value) } - defer func() { newIAMToken = origInject }() type fields struct { kube client.Client @@ -202,7 +206,7 @@ func TestConnect(t *testing.T) { if password == nil { t.Fatal("missing per-connection password provider") } - if got, err := password(context.Background()); err != nil || got != "iam-token" { + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { t.Errorf("token: %q, %v", got, err) } if _, ok := creds[xpv2.CredentialsSecretPasswordKey]; ok { @@ -248,7 +252,7 @@ func TestConnect(t *testing.T) { if password == nil { t.Fatal("missing per-connection password provider") } - if got, err := password(context.Background()); err != nil || got != "iam-token" { + if got, err := password(context.Background()); err != nil || !strings.Contains(got, "X-Amz-Signature=") { t.Errorf("token: %q, %v", got, err) } return mockDB{} @@ -259,6 +263,9 @@ func TestConnect(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &connector{kube: tc.fields.kube, track: tc.fields.track, newDB: tc.fields.newDB} + if tc.args.ctx == nil { + tc.args.ctx = context.Background() + } _, err := e.Connect(tc.args.ctx, tc.args.mg) if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) diff --git a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go index dde7c87e..ef6309a7 100644 --- a/pkg/controller/cluster/postgresql/default_privileges/reconciler.go +++ b/pkg/controller/cluster/postgresql/default_privileges/reconciler.go @@ -39,11 +39,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -52,8 +50,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errSelectDefaultPrivileges = "cannot select default privileges" errCreateDefaultPrivileges = "cannot create default privileges" errRevokeDefaultPrivileges = "cannot revoke default privileges" @@ -100,10 +96,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -142,24 +134,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.DefaultPrivileges) secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } return &external{db: c.newDB(secretData, database, sslMode, password)}, nil diff --git a/pkg/controller/cluster/postgresql/extension/reconciler.go b/pkg/controller/cluster/postgresql/extension/reconciler.go index 74819c1f..88af9edb 100644 --- a/pkg/controller/cluster/postgresql/extension/reconciler.go +++ b/pkg/controller/cluster/postgresql/extension/reconciler.go @@ -37,11 +37,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -50,8 +48,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errSelectExtension = "cannot select extension" errCreateExtension = "cannot create extension" errDropExtension = "cannot drop extension" @@ -92,10 +88,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -129,24 +121,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Extension) (manage secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } // We do not want to create an extension on the default DB diff --git a/pkg/controller/cluster/postgresql/grant/reconciler.go b/pkg/controller/cluster/postgresql/grant/reconciler.go index 906d1d4c..67eb938d 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/reconciler.go @@ -40,11 +40,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -53,8 +51,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errNotGrant = "managed resource is not a Grant custom resource" errSelectGrant = "cannot select grant" errCreateGrant = "cannot create grant" @@ -83,10 +79,6 @@ const ( versionUnknown = 0 ) -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client log logging.Logger @@ -136,24 +128,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } xdb := c.newDB(secretData, connectDatabase(mg.Spec.ForProvider, pc.Spec.DefaultDatabase), sslMode, password) diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 454f7a2b..e2340411 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -44,11 +44,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -57,8 +55,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errSelectRole = "cannot select role" errGetRoleFailed = "cannot get role" errCreateRole = "cannot create role" @@ -104,10 +100,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { Complete(r) } -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client apiReader client.Reader @@ -142,24 +134,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Role) (managed.Typ secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } return &external{ diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 960a0e65..b3b53382 100644 --- a/pkg/controller/cluster/postgresql/schema/reconciler.go +++ b/pkg/controller/cluster/postgresql/schema/reconciler.go @@ -39,11 +39,9 @@ import ( xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/crossplane-contrib/provider-sql/apis/cluster/postgresql/v1alpha1" - "github.com/crossplane-contrib/provider-sql/pkg/clients" - "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" "github.com/crossplane-contrib/provider-sql/pkg/clients/postgresql" "github.com/crossplane-contrib/provider-sql/pkg/clients/xsql" - postgresqltls "github.com/crossplane-contrib/provider-sql/pkg/controller/postgresql/tls" + "github.com/crossplane-contrib/provider-sql/pkg/controller/cluster/postgresql/connect" ) const ( @@ -52,8 +50,6 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errGenerateIAMToken = "cannot generate AWS IAM authentication token" - errSelectSchema = "cannot select schema" errCreateSchema = "cannot create schema" errDropSchema = "cannot drop schema" @@ -98,10 +94,6 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { var _ managed.TypedExternalConnector[*v1alpha1.Schema] = &connector{} -// newIAMToken prepares a token provider used for every physical connection. -// Tests replace this factory to avoid AWS access. -var newIAMToken = awsiam.NewTokenProvider - type connector struct { kube client.Client track func(ctx context.Context, mg resource.LegacyManaged) error @@ -137,24 +129,9 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Schema) (managed.T secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - sslMode := clients.ToString(pc.Spec.SSLMode) - var password xsql.PasswordProvider - if pc.Spec.Credentials.Source == v1alpha1.CredentialsSourceAWSIAMAuth { - var err error - password, err = newIAMToken(ctx, pc.Spec.Credentials.Region, secretData) - if err != nil { - return nil, errors.Wrap(err, errGenerateIAMToken) - } - sslMode = postgresql.EnsureTLS(sslMode) - } - - var caRef *xpv2.SecretKeySelector - if pc.Spec.TLSConfig != nil { - caRef = &pc.Spec.TLSConfig.CACert.SecretRef - } - sslMode, err := postgresqltls.LoadConfig(ctx, c.kube, "cluster/ProviderConfig/"+pc.Name, sslMode, string(secretData[xpv2.CredentialsSecretEndpointKey]), caRef) + sslMode, password, err := connect.Configure(ctx, c.kube, pc, secretData) if err != nil { - return nil, errors.Wrap(err, "cannot configure PostgreSQL TLS") + return nil, err } return &external{db: c.newDB(secretData, *mg.Spec.ForProvider.Database, sslMode, password)}, nil diff --git a/pkg/controller/namespaced/mysql/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 477b69e9..25515d80 100644 --- a/pkg/controller/namespaced/mysql/provider/provider.go +++ b/pkg/controller/namespaced/mysql/provider/provider.go @@ -7,7 +7,6 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "context" - "fmt" "github.com/crossplane-contrib/provider-sql/apis/namespaced/mysql/v1alpha1" "github.com/crossplane-contrib/provider-sql/pkg/clients/awsiam" @@ -35,12 +34,13 @@ var newIAMToken = awsiam.NewTokenProvider func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.ModernManaged) (ProviderInfo, error) { var ( - secretKey *client.ObjectKey - tlsMode *string - tlsConfig *v1alpha1.TLSConfig - keyMapping map[string]string - source v1alpha1.MySQLConnectionSecretSource - region *string + secretKey *client.ObjectKey + tlsMode *string + tlsConfig *v1alpha1.TLSConfig + keyMapping map[string]string + source v1alpha1.MySQLConnectionSecretSource + region *string + configIdentity string ) switch mg.GetProviderConfigReference().Kind { @@ -65,6 +65,7 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode keyMapping = providerConfig.Spec.Credentials.SecretKeyMapping.ToMap() source = providerConfig.Spec.Credentials.Source region = providerConfig.Spec.Credentials.Region + configIdentity = "ProviderConfig/" + providerConfig.Namespace + "/" + providerConfig.Name case v1alpha1.ClusterProviderConfigKind: clusterProviderConfig := &v1alpha1.ClusterProviderConfig{ @@ -86,6 +87,7 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode keyMapping = clusterProviderConfig.Spec.Credentials.SecretKeyMapping.ToMap() source = clusterProviderConfig.Spec.Credentials.Source region = clusterProviderConfig.Spec.Credentials.Region + configIdentity = "ClusterProviderConfig/" + clusterProviderConfig.Name default: return ProviderInfo{}, errors.InvalidProviderConfigKindError(mg.GetProviderConfigReference().Kind) @@ -118,7 +120,7 @@ func GetProviderConfig(ctx context.Context, kube client.Client, mg resource.Mode return ProviderInfo{ PasswordProvider: password, - ProviderConfigName: fmt.Sprintf("%s/%s/%s", mg.GetProviderConfigReference().Kind, mg.GetNamespace(), mg.GetProviderConfigReference().Name), + ProviderConfigName: configIdentity, SecretData: secretData, TLS: tlsMode, TLSConfig: tlsConfig, diff --git a/pkg/controller/namespaced/mysql/provider/provider_test.go b/pkg/controller/namespaced/mysql/provider/provider_test.go index db21f73f..fe094926 100644 --- a/pkg/controller/namespaced/mysql/provider/provider_test.go +++ b/pkg/controller/namespaced/mysql/provider/provider_test.go @@ -121,3 +121,36 @@ func TestGetProviderConfigTLS(t *testing.T) { } } } + +func TestTLSRegistrationIdentityUsesConfigurationScope(t *testing.T) { + for _, tc := range []struct{ kind, namespace, want string }{ + {v1alpha1.ClusterProviderConfigKind, "one", "ClusterProviderConfig/shared"}, + {v1alpha1.ClusterProviderConfigKind, "two", "ClusterProviderConfig/shared"}, + {v1alpha1.ProviderConfigKind, "one", "ProviderConfig/one/shared"}, + {v1alpha1.ProviderConfigKind, "two", "ProviderConfig/two/shared"}, + } { + t.Run(tc.kind+"/"+tc.namespace, func(t *testing.T) { + kube := &test.MockClient{MockGet: test.NewMockGetFn(nil, func(object client.Object) error { + switch pc := object.(type) { + case *v1alpha1.ProviderConfig: + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + pc.Spec.Credentials.ConnectionSecretRef = xpv2.LocalSecretReference{Name: "connection"} + case *v1alpha1.ClusterProviderConfig: + pc.Spec.Credentials.Source = v1alpha1.CredentialsSourceMySQLConnectionSecret + pc.Spec.Credentials.ConnectionSecretRef = xpv2.SecretReference{Name: "connection", Namespace: "trust"} + case *corev1.Secret: + pc.Data = map[string][]byte{} + } + return nil + })} + mg := &v1alpha1.Database{ObjectMeta: metav1.ObjectMeta{Namespace: tc.namespace}, Spec: v1alpha1.DatabaseSpec{ManagedResourceSpec: xpv2.ManagedResourceSpec{ProviderConfigReference: &xpv2.ProviderConfigReference{Kind: tc.kind, Name: "shared"}}}} + info, err := GetProviderConfig(context.Background(), kube, mg) + if err != nil { + t.Fatal(err) + } + if info.ProviderConfigName != tc.want { + t.Fatalf("registration identity: got %q, want %q", info.ProviderConfigName, tc.want) + } + }) + } +} diff --git a/pkg/controller/postgresql/tls/lifecycle_test.go b/pkg/controller/postgresql/tls/lifecycle_test.go index 8cbb45d4..89956562 100644 --- a/pkg/controller/postgresql/tls/lifecycle_test.go +++ b/pkg/controller/postgresql/tls/lifecycle_test.go @@ -148,7 +148,10 @@ func TestReloadWaiterCancellation(t *testing.T) { defer cancel() observed := &waitingContext{Context: ctx, waiting: make(chan struct{})} done := make(chan error, 1) - go func() { _, err := r.load(observed, nil, "waiting", "verify-full", "db.example", nil); done <- err }() + go func() { + _, err := r.load(observed, nil, "waiting", "verify-full", "db.example", lifecycleRef()) + done <- err + }() select { case <-observed.waiting: case <-time.After(5 * time.Second): @@ -328,3 +331,80 @@ func TestSelectorChangesFailClosed(t *testing.T) { }) } } + +func TestNoCAReconcileBypassesBusyRegistry(t *testing.T) { + r := newTLSRegistry() + if err := r.lock(context.Background()); err != nil { + t.Fatal(err) + } + defer r.unlock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + mode, err := r.load(ctx, nil, "unused", "require", "db.example", nil) + if err != nil || mode != "require" { + t.Fatalf("no-CA reconcile waited behind the registry: mode=%q err=%v", mode, err) + } +} + +func TestNoCAReconcilePreservesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := newTLSRegistry().load(ctx, nil, "unused", "verify-full", "db.example", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } +} + +func TestSelectorRemovalWaitsForFirstCARead(t *testing.T) { + r := newTLSRegistry() + started, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + ca := testCA(t) + kube := &test.MockClient{MockGet: func(_ context.Context, _ client.ObjectKey, object client.Object) error { + close(started) + <-release + object.(*corev1.Secret).Data = map[string][]byte{"bundle": ca} + return nil + }} + type result struct { + mode string + err error + } + loaded := make(chan result, 1) + go func() { + mode, err := r.load(context.Background(), kube, "first-load", "verify-full", "db.example", lifecycleRef()) + loaded <- result{mode, err} + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("CA read did not start") + } + waiting := &waitingContext{Context: context.Background(), waiting: make(chan struct{})} + removed := make(chan result, 1) + go func() { + mode, err := r.load(waiting, nil, "first-load", "verify-full", "db.example", nil) + removed <- result{mode, err} + }() + select { + case <-waiting.waiting: + case result := <-removed: + t.Fatalf("selector removal bypassed an in-progress CA read: %+v", result) + case <-time.After(5 * time.Second): + t.Fatal("selector removal did not reach the gate") + } + releaseOnce.Do(func() { close(release) }) + first := <-loaded + if first.err != nil { + t.Fatal(first.err) + } + next := <-removed + if next.err != nil || next.mode != "verify-full" { + t.Fatalf("selector removal: %+v", next) + } + assertRegistered(t, first.mode, false) + if len(r.entries) != 0 { + t.Fatal("removed selector retained its registration") + } +} diff --git a/pkg/controller/postgresql/tls/tls.go b/pkg/controller/postgresql/tls/tls.go index fd6624ab..d2201260 100644 --- a/pkg/controller/postgresql/tls/tls.go +++ b/pkg/controller/postgresql/tls/tls.go @@ -21,6 +21,7 @@ import ( "context" "crypto/sha256" "fmt" + "sync" xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" "github.com/lib/pq" @@ -37,13 +38,17 @@ type registration struct { } type tlsRegistry struct { + // Activity counts registered configurations and in-progress CA loads. It is + // separate from the gate so an unrelated no-CA reconcile never waits for I/O. + activityMu sync.RWMutex + activity map[string]int gate chan struct{} entries map[string]registration generation uint64 } func newTLSRegistry() *tlsRegistry { - return &tlsRegistry{gate: make(chan struct{}, 1), entries: make(map[string]registration)} + return &tlsRegistry{gate: make(chan struct{}, 1), entries: make(map[string]registration), activity: make(map[string]int)} } var defaultRegistry = newTLSRegistry() @@ -62,11 +67,28 @@ func (r *tlsRegistry) lock(ctx context.Context) error { func (r *tlsRegistry) unlock() { <-r.gate } +func (r *tlsRegistry) track(identity string, delta int) { + r.activityMu.Lock() + defer r.activityMu.Unlock() + if n := r.activity[identity] + delta; n > 0 { + r.activity[identity] = n + } else { + delete(r.activity, identity) + } +} + +func (r *tlsRegistry) active(identity string) bool { + r.activityMu.RLock() + defer r.activityMu.RUnlock() + return r.activity[identity] != 0 +} + // forget is called while holding the registry lock. func (r *tlsRegistry) forget(identity string) { if old, ok := r.entries[identity]; ok { _ = pq.RegisterTLSConfig(old.name, nil) delete(r.entries, identity) + r.track(identity, -1) } } @@ -79,6 +101,20 @@ func LoadConfig(ctx context.Context, kube client.Client, identity, mode, endpoin } func (r *tlsRegistry) load(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if ref == nil { + if !r.active(identity) { + return mode, nil + } + } else { + // Mark this load before waiting for the gate. Removing a selector must not + // return early while its first CA read is still in progress. + r.track(identity, 1) + defer r.track(identity, -1) + } + // Serialize the complete read/parse/register sequence. Otherwise an older // Secret read could overwrite a newer bundle from a concurrent reconcile. if err := r.lock(ctx); err != nil { @@ -89,6 +125,11 @@ func (r *tlsRegistry) load(ctx context.Context, kube client.Client, identity, mo r.forget(identity) return mode, nil } + return r.register(ctx, kube, identity, mode, endpoint, ref) +} + +// register requires the gate to cover the complete read/parse/register sequence. +func (r *tlsRegistry) register(ctx context.Context, kube client.Client, identity, mode, endpoint string, ref *xpv2.SecretKeySelector) (string, error) { if mode == "" { mode = "verify-full" } @@ -106,7 +147,12 @@ func (r *tlsRegistry) load(ctx context.Context, kube client.Client, identity, mo if err := pq.RegisterTLSConfig(name, config); err != nil { return "", fmt.Errorf("cannot register PostgreSQL TLS configuration: %w", err) } - if previous, ok := r.entries[identity]; ok && previous.name != name { + if previous, ok := r.entries[identity]; !ok { + r.track(identity, 1) + } else if previous.name != name { + // Retire the old registration immediately. A reconcile holding its name may + // fail to open its next connection and retry; established connections stay + // open. Updating the CA contents keeps the same name and avoids this case. _ = pq.RegisterTLSConfig(previous.name, nil) } r.generation++ From b5cfba73edaae0e97dba6731b7be407f1c122c4b Mon Sep 17 00:00:00 2001 From: michielvha Date: Fri, 30 Oct 2026 17:32:11 +0100 Subject: [PATCH 11/11] test: isolate AWS settings and guard dynamic PostgreSQL DSNs Signed-off-by: michielvha --- pkg/clients/postgresql/postgresql_test.go | 44 +++++++++++++++++++ .../cluster/mysql/database/reconciler_test.go | 7 +++ .../postgresql/database/reconciler_test.go | 7 +++ 3 files changed, 58 insertions(+) diff --git a/pkg/clients/postgresql/postgresql_test.go b/pkg/clients/postgresql/postgresql_test.go index 77723587..11a56d4f 100644 --- a/pkg/clients/postgresql/postgresql_test.go +++ b/pkg/clients/postgresql/postgresql_test.go @@ -2,6 +2,7 @@ package postgresql import ( "context" + "os" "strings" "testing" @@ -102,3 +103,46 @@ func TestEnsureTLS(t *testing.T) { }) } } + +func TestPasswordProviderConstructorDoesNotRetainStoredPassword(t *testing.T) { + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "PG") { + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } + } + const stored = "STORED-PASSWORD-MUST-NOT-BE-RETAINED" + creds := map[string][]byte{ + xpv2.CredentialsSecretEndpointKey: []byte("db.example"), + xpv2.CredentialsSecretPortKey: []byte("5432"), + xpv2.CredentialsSecretUserKey: []byte("user"), + xpv2.CredentialsSecretPasswordKey: []byte(stored), + } + calls := 0 + password := func(context.Context) (string, error) { calls++; return "fresh-token", nil } + dynamic := newPostgresDB(creds, "postgres", "verify-full", password) + if strings.Contains(dynamic.dsn, stored) { + t.Fatal("dynamic connection retained the stored password in its DSN") + } + cfg, err := pq.NewConfig(dynamic.dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Password != "" { + t.Fatal("dynamic connection parsed a retained password") + } + if calls != 0 || dynamic.passwordProvider == nil { + t.Fatal("constructor changed when passwords are generated") + } + static := newPostgresDB(creds, "postgres", "verify-full", nil) + cfg, err = pq.NewConfig(static.dsn) + if err != nil || cfg.Password != stored { + t.Fatalf("static password behavior changed: %v", err) + } + if string(creds[xpv2.CredentialsSecretPasswordKey]) != stored { + t.Fatal("constructor modified the caller's credentials") + } +} diff --git a/pkg/controller/cluster/mysql/database/reconciler_test.go b/pkg/controller/cluster/mysql/database/reconciler_test.go index e26346ca..3668780f 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -68,6 +68,13 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Keep this real-signing test independent of the developer's AWS configuration. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "AWS_") { + t.Setenv(key, "") + } + } for key, value := range map[string]string{ "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true", diff --git a/pkg/controller/cluster/postgresql/database/reconciler_test.go b/pkg/controller/cluster/postgresql/database/reconciler_test.go index 412fe677..6858b66b 100644 --- a/pkg/controller/cluster/postgresql/database/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/database/reconciler_test.go @@ -75,6 +75,13 @@ func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } + // Keep this real-signing test independent of the developer's AWS configuration. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "AWS_") { + t.Setenv(key, "") + } + } for key, value := range map[string]string{ "AWS_ACCESS_KEY_ID": "SYNTHETIC-TEST-KEY", "AWS_SECRET_ACCESS_KEY": "synthetic-test-secret", "AWS_SESSION_TOKEN": "synthetic-session", "AWS_REGION": "eu-central-1", "AWS_EC2_METADATA_DISABLED": "true",