diff --git a/apis/cluster/mysql/v1alpha1/provider_types.go b/apis/cluster/mysql/v1alpha1/provider_types.go index 4762126b..1d6fac12 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 xpv1.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 xpv1.CredentialsSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source xpv1.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 fff8a6cc..a3b8ed31 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -499,6 +499,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 66cf03dd..b301c31f 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 xpv1.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 xpv1.CredentialsSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret + // +kubebuilder:validation:Enum=PostgreSQLConnectionSecret;AWSIAMAuth Source xpv1.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 d3590fd0..12061f20 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 ee61542a..9f356041 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 280e2fe2..2ddf82f1 100644 --- a/apis/namespaced/mysql/v1alpha1/provider_types.go +++ b/apis/namespaced/mysql/v1alpha1/provider_types.go @@ -62,12 +62,17 @@ const ( // should acquire credentials from a connection secret written by a managed // resource that represents a MySQL server. CredentialsSourceMySQLConnectionSecret MySQLConnectionSecretSource = "MySQLConnectionSecret" + + // CredentialsSourceAWSIAMAuth indicates that the provider should + // authenticate to an AWS RDS/Aurora instance using an IAM authentication + // token generated at connection time, instead of a static password. + CredentialsSourceAWSIAMAuth MySQLConnectionSecretSource = "AWSIAMAuth" ) // ProviderCredentials required to authenticate. type ProviderCredentials struct { // Source of the provider credentials. - // +kubebuilder:validation:Enum=MySQLConnectionSecret + // +kubebuilder:validation:Enum=MySQLConnectionSecret;AWSIAMAuth Source MySQLConnectionSecretSource `json:"source"` // A CredentialsSecretRef is a reference to a MySQL connection secret @@ -80,6 +85,12 @@ type ProviderCredentials struct { // standard Crossplane keys are used: "endpoint", "port", "username", "password". // +optional SecretKeyMapping *SecretKeyMapping `json:"secretKeyMapping,omitempty"` + + // Region is the AWS region used to generate the IAM authentication token + // when source is AWSIAMAuth. When unset it falls back to a "region" key in + // the connection secret, then to the controller's AWS environment. + // +optional + Region *string `json:"region,omitempty"` } // SecretKeyMapping allows overriding the default secret key names used to diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index fae51bbd..97529333 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -123,6 +123,11 @@ func (in *ClusterProviderCredentials) DeepCopyInto(out *ClusterProviderCredentia *out = new(SecretKeyMapping) **out = **in } + if in.Region != nil { + in, out := &in.Region, &out.Region + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderCredentials. @@ -617,6 +622,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 a2f0db07..74feab7d 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 e1597550..2ab3b129 100644 --- a/apis/namespaced/postgresql/v1alpha1/provider_types.go +++ b/apis/namespaced/postgresql/v1alpha1/provider_types.go @@ -46,12 +46,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 @@ -64,6 +69,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 25131b3f..a4a6025f 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/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 diff --git a/go.mod b/go.mod index 7e52e6b5..a8b66703 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.1 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.2.1 github.com/crossplane/crossplane-tools v0.0.0-20250731192036-00d407d8b7ec github.com/crossplane/upjet/v2 v2.2.0 @@ -28,6 +31,18 @@ require ( github.com/antchfx/htmlquery v1.2.4 // indirect github.com/antchfx/xpath v1.3.6 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index 7695881c..e56a3331 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,36 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29 h1:1Hbcvm9a/7hBCAM5y5SAvSKyFUsULrMgmS+XBx32u68= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29/go.mod h1:qIWWBwh4Wp7HU4E8AkMtu9pqHJ6DxmqLFX7zV8ZX4lM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= diff --git a/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml b/package/crds/mysql.sql.crossplane.io_providerconfigs.yaml index e1271ea2..8c9d0b09 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 4d878d84..1aea8cd8 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 58d6ee69..3dd867fe 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 64c3abfe..e1431cb4 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 5b2484f3..732d6ef1 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 0996d2d3..52d61807 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 diff --git a/pkg/clients/awsiam/awsiam.go b/pkg/clients/awsiam/awsiam.go new file mode 100644 index 00000000..1fc4dad5 --- /dev/null +++ b/pkg/clients/awsiam/awsiam.go @@ -0,0 +1,108 @@ +/* +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/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" +) + +// regionKey is the optional connection-secret key used to resolve the AWS +// region when it is not set on the ProviderConfig. +const regionKey = "region" + +const ( + errMissingConnDetails = "connection secret must contain endpoint, port and username for AWS IAM authentication" + errNoRegion = "AWS region could not be resolved: set spec.credentials.region, a \"region\" key in the connection secret, or the controller's AWS region (e.g. AWS_REGION)" + errBuildToken = "cannot generate AWS RDS IAM authentication token" + errLoadConfig = "cannot load AWS configuration" +) + +// TokenBuilder has the same signature as auth.BuildAuthToken from the AWS SDK. +// 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 +} + +// 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/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") + } + }) +} diff --git a/pkg/clients/mysql/mysql.go b/pkg/clients/mysql/mysql.go index 4ab55902..c6d8f362 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[xpv1.ResourceCredentialsSecretEndpointKey]) port := string(creds[xpv1.ResourceCredentialsSecretPortKey]) username := string(creds[xpv1.ResourceCredentialsSecretUserKey]) @@ -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,33 @@ 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 { +// 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 { // 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) + } + if cleartext { + dsn += "&allowCleartextPasswords=true" } - return fmt.Sprintf("%s:%s@tcp(%s:%s)/?tls=%s", - username, - password, - endpoint, - port, - tls) + return dsn } // ExecTx is unsupported in MySQL. diff --git a/pkg/clients/mysql/mysql_test.go b/pkg/clients/mysql/mysql_test.go index 13aacdc6..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 86443b59..d398df59 100644 --- a/pkg/controller/cluster/mysql/database/reconciler.go +++ b/pkg/controller/cluster/mysql/database/reconciler.go @@ -36,6 +36,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "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,10 +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 + 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 +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)}, 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 2928bda1..1c2a2cf9 100644 --- a/pkg/controller/cluster/mysql/database/reconciler_test.go +++ b/pkg/controller/cluster/mysql/database/reconciler_test.go @@ -66,10 +66,18 @@ 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 - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB } type args struct { @@ -161,6 +169,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 50546675..24bffa4e 100644 --- a/pkg/controller/cluster/mysql/grant/reconciler.go +++ b/pkg/controller/cluster/mysql/grant/reconciler.go @@ -40,6 +40,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "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,10 +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 + 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 +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)}, 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/grant/reconciler_test.go b/pkg/controller/cluster/mysql/grant/reconciler_test.go index 39d95b3a..6e343523 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 3eba36a6..26d2861f 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -39,6 +39,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "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,10 +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 + 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{} @@ -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), + db: c.newDB(secretData, tlsName, mg.Spec.ForProvider.BinLog, cleartext), kube: c.kube, }, nil } diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index 8d88abd0..916b7528 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -77,7 +77,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/postgresql/database/reconciler.go b/pkg/controller/cluster/postgresql/database/reconciler.go index 0bcafae5..329793f3 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 711418e1..ba519d66 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 13e3b34b..338c62fd 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" @@ -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 @@ -133,7 +140,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 be93e002..e11299f2 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 802bc3e9..abd66172 100644 --- a/pkg/controller/cluster/postgresql/grant/reconciler.go +++ b/pkg/controller/cluster/postgresql/grant/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" + errNotGrant = "managed resource is not a Grant custom resource" errSelectGrant = "cannot select grant" errCreateGrant = "cannot create grant" @@ -67,6 +70,10 @@ const ( maxConcurrency = 5 ) +// 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 @@ -120,7 +127,15 @@ func (c *connector) Connect(ctx context.Context, mg *v1alpha1.Grant) (managed.Ty db = pc.Spec.DefaultDatabase } secretData := xsql.RemapCredentialKeys(s.Data, pc.Spec.Credentials.SecretKeyMapping.ToMap()) - xdb := c.newDB(secretData, db, 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, db, sslMode) serverVersion, err := xdb.GetServerVersion(ctx) if err != nil { diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index ae88ba5d..d95e183e 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -44,6 +44,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" ) @@ -54,6 +55,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" errCreateRole = "cannot create role" errDropRole = "cannot drop role" @@ -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,8 +139,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, }, nil } diff --git a/pkg/controller/cluster/postgresql/schema/reconciler.go b/pkg/controller/cluster/postgresql/schema/reconciler.go index 6fd4e769..f176e59d 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{} 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 55d94991..d5f3998f 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, providerInfo.Cleartext)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/database/reconciler_test.go b/pkg/controller/namespaced/mysql/database/reconciler_test.go index 8d0abd2c..35d6e9b7 100644 --- a/pkg/controller/namespaced/mysql/database/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/database/reconciler_test.go @@ -71,7 +71,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 fd431e85..7efd81ea 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, providerInfo.Cleartext)}, nil } type external struct{ db xsql.DB } diff --git a/pkg/controller/namespaced/mysql/grant/reconciler_test.go b/pkg/controller/namespaced/mysql/grant/reconciler_test.go index 41fb1802..7a89ee32 100644 --- a/pkg/controller/namespaced/mysql/grant/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/grant/reconciler_test.go @@ -80,7 +80,7 @@ func TestConnect(t *testing.T) { type fields struct { kube client.Client track func(context.Context, resource.ModernManaged) error - newDB func(creds map[string][]byte, tls *string, binlog *bool) xsql.DB + newDB func(creds map[string][]byte, tls *string, binlog *bool, cleartext bool) xsql.DB } type args struct { diff --git a/pkg/controller/namespaced/mysql/provider/provider.go b/pkg/controller/namespaced/mysql/provider/provider.go index 908dfc80..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 de6f2189..697fac80 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, providerInfo.Cleartext), kube: c.kube, }, nil } diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index 23e9cb56..8de79bc0 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 { 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) + } +}