From c3f634b678cb91103780e0b08763e00f5afc41cf Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Tue, 3 Sep 2024 01:01:09 -0700 Subject: [PATCH] Add support for GCP IAM impersonation (#448) Add support for GCP IAM service account impersonation ### Use cases The company has a centralized service account that is used for Terraform automation. However, such GSA should not be used to access the database directly where each database will have its own IAM DB users. This added an option to impersonate the database IAM user via the centralized GSA. As long as the centralized GSA has sufficient permissions to impersonate as the database IAM DB user, it can be used to perform database automation in Terraform. ### Testing ```hcl resource "google_sql_database_instance" "self" {} resource "google_sql_user" "admin" {} resource "google_service_account" "db_iam_admin" {} resource "google_sql_user" "iam_admin" { name = trimsuffix(google_service_account.db_iam_admin.email, ".gserviceaccount.com") instance = google_sql_database_instance.self.name type = "CLOUD_IAM_SERVICE_ACCOUNT" } resource "google_project_iam_member" "iam_admin_project_iam_members" { for_each = toset(["roles/cloudsql.client", "roles/cloudsql.instanceUser"]) member = google_service_account.db_iam_admin.member role = each.key } provider "postgresql" { scheme = "gcppostgres" host = google_sql_database_instance.self.connection_name username = trimsuffix(google_service_account.db_iam_admin.email, ".gserviceaccount.com") gcp_iam_impersonate_service_account = google_service_account.db_iam_admin.email port = 5432 superuser = false alias = "iamAdmin" } # it should work and able to apply resources using the IAM db user resource "postgresql_*" "*" { provider = postgresql.iamAdmin // * } ``` --- go.mod | 2 +- postgresql/config.go | 59 +++++++++++++++++++++++--------- postgresql/provider.go | 37 ++++++++++++-------- website/docs/index.html.markdown | 23 ++++++++++++- 4 files changed, 89 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index fc4c880a..5d4290d5 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( gocloud.dev v0.34.0 golang.org/x/net v0.26.0 golang.org/x/oauth2 v0.10.0 + google.golang.org/api v0.134.0 ) require ( @@ -91,7 +92,6 @@ require ( golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/api v0.134.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect google.golang.org/grpc v1.57.0 // indirect diff --git a/postgresql/config.go b/postgresql/config.go index c4ed30e8..17668c0e 100644 --- a/postgresql/config.go +++ b/postgresql/config.go @@ -12,9 +12,12 @@ import ( "github.com/blang/semver" _ "github.com/lib/pq" // PostgreSQL db + "gocloud.dev/gcp" + "gocloud.dev/gcp/cloudsql" "gocloud.dev/postgres" _ "gocloud.dev/postgres/awspostgres" - _ "gocloud.dev/postgres/gcppostgres" + "gocloud.dev/postgres/gcppostgres" + "google.golang.org/api/impersonate" ) type featureName uint @@ -157,21 +160,22 @@ type ClientCertificateConfig struct { // Config - provider config type Config struct { - Scheme string - Host string - Port int - Username string - Password string - DatabaseUsername string - Superuser bool - SSLMode string - ApplicationName string - Timeout int - ConnectTimeoutSec int - MaxConns int - ExpectedVersion semver.Version - SSLClientCert *ClientCertificateConfig - SSLRootCertPath string + Scheme string + Host string + Port int + Username string + Password string + DatabaseUsername string + Superuser bool + SSLMode string + ApplicationName string + Timeout int + ConnectTimeoutSec int + MaxConns int + ExpectedVersion semver.Version + SSLClientCert *ClientCertificateConfig + SSLRootCertPath string + GCPIAMImpersonateServiceAccount string } // Client struct holding connection string @@ -280,6 +284,8 @@ func (c *Client) Connect() (*DBConnection, error) { var err error if c.config.Scheme == "postgres" { db, err = sql.Open(proxyDriverName, dsn) + } else if c.config.Scheme == "gcppostgres" && c.config.GCPIAMImpersonateServiceAccount != "" { + db, err = openImpersonatedGCPDBConnection(context.Background(), dsn, c.config.GCPIAMImpersonateServiceAccount) } else { db, err = postgres.Open(context.Background(), dsn) } @@ -345,3 +351,24 @@ func fingerprintCapabilities(db *sql.DB) (*semver.Version, error) { return &version, nil } + +func openImpersonatedGCPDBConnection(ctx context.Context, dsn string, targetServiceAccountEmail string) (*sql.DB, error) { + ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: targetServiceAccountEmail, + Scopes: []string{"https://www.googleapis.com/auth/sqlservice.admin"}, + }) + if err != nil { + return nil, fmt.Errorf("Error creating token source with service account impersonation of %s: %w", targetServiceAccountEmail, err) + } + client, err := gcp.NewHTTPClient(gcp.DefaultTransport(), ts) + if err != nil { + return nil, fmt.Errorf("Error creating HTTP client with service account impersonation of %s: %w", targetServiceAccountEmail, err) + } + certSource := cloudsql.NewCertSourceWithIAM(client, ts) + opener := gcppostgres.URLOpener{CertSource: certSource} + dbURL, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("Error parsing connection string: %w", err) + } + return opener.OpenPostgresURL(ctx, dbURL) +} diff --git a/postgresql/provider.go b/postgresql/provider.go index 7f15c92e..2743d7b6 100644 --- a/postgresql/provider.go +++ b/postgresql/provider.go @@ -3,9 +3,10 @@ package postgresql import ( "context" "fmt" + "os" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "os" "github.com/blang/semver" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -103,6 +104,13 @@ func Provider() *schema.Provider { Description: "MS Azure tenant ID (see: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config.html)", }, + "gcp_iam_impersonate_service_account": { + Type: schema.TypeString, + Optional: true, + Default: "", + Description: "Service account to impersonate when using GCP IAM authentication.", + }, + // Conection username can be different than database username with user name mapas (e.g.: in Azure) // See https://www.postgresql.org/docs/current/auth-username-maps.html "database_username": { @@ -323,19 +331,20 @@ func providerConfigure(d *schema.ResourceData) (interface{}, error) { } config := Config{ - Scheme: d.Get("scheme").(string), - Host: host, - Port: port, - Username: username, - Password: password, - DatabaseUsername: d.Get("database_username").(string), - Superuser: d.Get("superuser").(bool), - SSLMode: sslMode, - ApplicationName: "Terraform provider", - ConnectTimeoutSec: d.Get("connect_timeout").(int), - MaxConns: d.Get("max_connections").(int), - ExpectedVersion: version, - SSLRootCertPath: d.Get("sslrootcert").(string), + Scheme: d.Get("scheme").(string), + Host: host, + Port: port, + Username: username, + Password: password, + DatabaseUsername: d.Get("database_username").(string), + Superuser: d.Get("superuser").(bool), + SSLMode: sslMode, + ApplicationName: "Terraform provider", + ConnectTimeoutSec: d.Get("connect_timeout").(int), + MaxConns: d.Get("max_connections").(int), + ExpectedVersion: version, + SSLRootCertPath: d.Get("sslrootcert").(string), + GCPIAMImpersonateServiceAccount: d.Get("gcp_iam_impersonate_service_account").(string), } if value, ok := d.GetOk("clientcert"); ok { diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index a2b82bc0..30959d90 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -214,7 +214,28 @@ To enable GoCloud for GCP SQL, set `scheme` to `gcppostgres` and `host` to the c For GCP, GoCloud also requires the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to be set to the service account credentials file. These credentials can be created here: https://console.cloud.google.com/iam-admin/serviceaccounts -See also: https://cloud.google.com/docs/authentication/production +In addition, the provider supports service account impersonation with the `gcp_iam_impersonate_service_account` option. You must ensure: + +- The IAM database user has sufficient permissions to connect to the database, e.g., `roles/cloudsql.instanceUser` +- The principal (IAM user or IAM service account) behind the `GOOGLE_APPLICATION_CREDENTIALS` has sufficient permissions to impersonate the provided service account. Learn more from [roles for service account authentication](https://cloud.google.com/iam/docs/service-account-permissions). + +```hcl +provider "postgresql" { + scheme = "gcppostgres" + host = "test-project/europe-west3/test-instance" + port = 5432 + + username = "service_account_id@$project_id.iam" + gcp_iam_impersonate_service_account = "service_account_id@$project_id.iam.gserviceaccount.com" + + superuser = false +} +``` + +See also: + +- https://cloud.google.com/docs/authentication/production +- https://cloud.google.com/sql/docs/postgres/iam-logins --- **Note**