From 3347d3b33c7177f5f16384e995db2606ecc784e6 Mon Sep 17 00:00:00 2001 From: Eno Compton Date: Tue, 6 Dec 2022 16:21:43 -0700 Subject: [PATCH] feat: add support for JSON credentials (#188) This is a port of https://github.com/GoogleCloudPlatform/cloud-sql-proxy/pull/1433. --- .envrc.example | 2 ++ cmd/root.go | 12 ++++++++++ cmd/root_test.go | 43 +++++++++++++++++++++++++++++++++--- internal/proxy/proxy.go | 8 +++++++ internal/proxy/proxy_test.go | 1 + tests/alloydb_test.go | 26 ++++++++++++++++------ tests/connection_test.go | 32 ++++++++++++++++++++------- 7 files changed, 106 insertions(+), 18 deletions(-) diff --git a/.envrc.example b/.envrc.example index 4858d65f..63468994 100644 --- a/.envrc.example +++ b/.envrc.example @@ -2,3 +2,5 @@ export ALLOYDB_CONNECTION_NAME="project:region:instance" export ALLOYDB_USER="postgres-user" export ALLOYDB_PASS="postgres-password" export ALLOYDB_DB="postgres-db-name" + +export GOOGLE_APPLICATION_CREDENTIALS="path/to/credentials" diff --git a/cmd/root.go b/cmd/root.go index a27c5071..a22ea6cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -162,6 +162,8 @@ without having to manage any client SSL certificates.`, "Bearer token used for authorization.") cmd.PersistentFlags().StringVarP(&c.conf.CredentialsFile, "credentials-file", "c", "", "Path to a service account key to use for authentication.") + cmd.PersistentFlags().StringVarP(&c.conf.CredentialsJSON, "json-credentials", "j", "", + "Use service account key JSON as a source of IAM credentials.") cmd.PersistentFlags().BoolVarP(&c.conf.GcloudAuth, "gcloud-auth", "g", false, "Use gcloud's user configuration to retrieve a token for authentication.") cmd.PersistentFlags().BoolVarP(&c.conf.StructuredLogs, "structured-logs", "l", false, @@ -259,6 +261,16 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error { if conf.CredentialsFile != "" && conf.GcloudAuth { return newBadCommandError("cannot specify --credentials-file and --gcloud-auth flags at the same time") } + if conf.CredentialsJSON != "" && conf.Token != "" { + return newBadCommandError("cannot specify --json-credentials and --token flags at the same time") + } + if conf.CredentialsJSON != "" && conf.CredentialsFile != "" { + return newBadCommandError("cannot specify --json-credentials and --credentials-file flags at the same time") + } + if conf.CredentialsJSON != "" && conf.GcloudAuth { + return newBadCommandError("cannot specify --json-credentials and --gcloud-auth flags at the same time") + } + if userHasSet("alloydbadmin-api-endpoint") { _, err := url.Parse(conf.APIEndpointURL) if err != nil { diff --git a/cmd/root_test.go b/cmd/root_test.go index 310b3e37..2c50dab4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -208,6 +208,19 @@ func TestNewCommandArguments(t *testing.T) { APIEndpointURL: "https://test.googleapis.com", }), }, + { + desc: "using the JSON credentials", + args: []string{"--json-credentials", `{"json":"goes-here"}`, "projects/proj/locations/region/clusters/clust/instances/inst"}, want: withDefaults(&proxy.Config{ + CredentialsJSON: `{"json":"goes-here"}`, + }), + }, + { + desc: "using the (short) JSON credentials", + args: []string{"-j", `{"json":"goes-here"}`, "projects/proj/locations/region/clusters/clust/instances/inst"}, + want: withDefaults(&proxy.Config{ + CredentialsJSON: `{"json":"goes-here"}`, + }), + }, } for _, tc := range tcs { @@ -329,13 +342,36 @@ func TestNewCommandWithErrors(t *testing.T) { desc: "when both token and gcloud auth are set", args: []string{ "--token", "my-token", - "--gcloud-auth", "proj:region:inst"}, + "--gcloud-auth", + "projects/proj/locations/region/clusters/clust/instances/inst"}, }, { desc: "when both gcloud auth and credentials file are set", args: []string{ "--gcloud-auth", - "--credential-file", "/path/to/file", "proj:region:inst"}, + "--credentials-file", "/path/to/file", + "projects/proj/locations/region/clusters/clust/instances/inst"}, + }, + { + desc: "when both token and credentials JSON are set", + args: []string{ + "--token", "a-token", + "--json-credentials", `{"json":"here"}`, + "projects/proj/locations/region/clusters/clust/instances/inst"}, + }, + { + desc: "when both credentials file and credentials JSON are set", + args: []string{ + "--credentials-file", "/a/file", + "--json-credentials", `{"json":"here"}`, + "projects/proj/locations/region/clusters/clust/instances/inst"}, + }, + { + desc: "when both gcloud auth and credentials JSON are set", + args: []string{ + "--gcloud-auth", + "--json-credentials", `{"json":"here"}`, + "projects/proj/locations/region/clusters/clust/instances/inst"}, }, { desc: "when the unix socket query param contains multiple values", @@ -359,7 +395,8 @@ func TestNewCommandWithErrors(t *testing.T) { }, { desc: "using an invalid url for host flag", - args: []string{"--host", "https://invalid:url[/]", "proj:region:inst"}, + args: []string{"--host", "https://invalid:url[/]", + "projects/proj/locations/region/clusters/clust/instances/inst"}, }, { desc: "using fuse-tmp-dir without fuse", diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 9a8395ac..2b0bf6ef 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -60,6 +60,9 @@ type Config struct { // CredentialsFile is the path to a service account key. CredentialsFile string + // CredentialsJSON is a JSON representation of the service account key. + CredentialsJSON string + // GcloudAuth set whether to use Gcloud's config helper to retrieve a // token for authentication. GcloudAuth bool @@ -131,6 +134,11 @@ func (c *Config) DialerOptions(l alloydb.Logger) ([]alloydbconn.Option, error) { return nil, err } opts = append(opts, alloydbconn.WithTokenSource(ts)) + case c.CredentialsJSON != "": + l.Infof("Authorizing with JSON credentials environment variable") + opts = append(opts, alloydbconn.WithCredentialsJSON( + []byte(c.CredentialsJSON), + )) default: l.Infof("Authorizing with Application Default Credentials") } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 7d61ae53..638fcc5a 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -211,6 +211,7 @@ func TestClientInitialization(t *testing.T) { } _, isFlex := os.LookupEnv("FLEX") if !isFlex { + // App Engine Flex doesn't support IPv6. tcs = append(tcs, testCase{ desc: "IPv6 support", in: &proxy.Config{ diff --git a/tests/alloydb_test.go b/tests/alloydb_test.go index 3c946ede..d3aa94db 100644 --- a/tests/alloydb_test.go +++ b/tests/alloydb_test.go @@ -100,7 +100,7 @@ func TestPostgresAuthWithToken(t *testing.T) { } _, isFlex := os.LookupEnv("FLEX") if isFlex { - t.Skip("disabling until we migrate tests to Kokoro") + t.Skip("App Engine Flex doesn't support retrieving OAuth2 tokens") } requirePostgresVars(t) cleanup, err := pgxv4.RegisterDriver("alloydb2") @@ -108,7 +108,7 @@ func TestPostgresAuthWithToken(t *testing.T) { t.Fatalf("failed to register driver: %v", err) } defer cleanup() - tok, _, cleanup2 := removeAuthEnvVar(t) + tok, _, cleanup2 := removeAuthEnvVar(t, true) defer cleanup2() dsn := fmt.Sprintf("host=%v user=%v password=%v database=%v sslmode=disable", @@ -122,17 +122,13 @@ func TestPostgresAuthWithCredentialsFile(t *testing.T) { if testing.Short() { t.Skip("skipping Postgres integration tests") } - _, isFlex := os.LookupEnv("FLEX") - if isFlex { - t.Skip("disabling until we migrate tests to Kokoro") - } requirePostgresVars(t) cleanup, err := pgxv4.RegisterDriver("alloydb3") if err != nil { t.Fatalf("failed to register driver: %v", err) } defer cleanup() - _, path, cleanup2 := removeAuthEnvVar(t) + _, path, cleanup2 := removeAuthEnvVar(t, false) defer cleanup2() dsn := fmt.Sprintf("host=%v user=%v password=%v database=%v sslmode=disable", @@ -142,6 +138,22 @@ func TestPostgresAuthWithCredentialsFile(t *testing.T) { "alloydb3", dsn) } +func TestPostgresAuthWithCredentialsJSON(t *testing.T) { + if testing.Short() { + t.Skip("skipping Postgres integration tests") + } + requirePostgresVars(t) + creds := keyfile(t) + _, _, cleanup := removeAuthEnvVar(t, false) + defer cleanup() + + dsn := fmt.Sprintf("host=localhost user=%s password=%s database=%s sslmode=disable", + *alloydbUser, *alloydbPass, *alloydbDB) + proxyConnTest(t, + []string{"--json-credentials", string(creds), *alloydbConnName}, + "pgx", dsn) +} + func TestAuthWithGcloudAuth(t *testing.T) { if testing.Short() { t.Skip("skipping Postgres integration tests") diff --git a/tests/connection_test.go b/tests/connection_test.go index 4cf9d7b3..1ec27cff 100644 --- a/tests/connection_test.go +++ b/tests/connection_test.go @@ -17,6 +17,7 @@ package tests import ( "context" "database/sql" + "io/ioutil" "os" "testing" "time" @@ -30,14 +31,17 @@ const connTestTimeout = time.Minute // removeAuthEnvVar retrieves an OAuth2 token and a path to a service account key // and then unsets GOOGLE_APPLICATION_CREDENTIALS. It returns a cleanup function // that restores the original setup. -func removeAuthEnvVar(t *testing.T) (*oauth2.Token, string, func()) { - ts, err := google.DefaultTokenSource(context.Background()) - if err != nil { - t.Errorf("failed to resolve token source: %v", err) - } - tok, err := ts.Token() - if err != nil { - t.Errorf("failed to get token: %v", err) +func removeAuthEnvVar(t *testing.T, wantToken bool) (*oauth2.Token, string, func()) { + var tok *oauth2.Token + if wantToken { + ts, err := google.DefaultTokenSource(context.Background()) + if err != nil { + t.Errorf("failed to resolve token source: %v", err) + } + tok, err = ts.Token() + if err != nil { + t.Errorf("failed to get token: %v", err) + } } path, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") if !ok { @@ -51,6 +55,18 @@ func removeAuthEnvVar(t *testing.T) (*oauth2.Token, string, func()) { } } +func keyfile(t *testing.T) string { + path := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if path == "" { + t.Fatal("GOOGLE_APPLICATION_CREDENTIALS not set") + } + creds, err := ioutil.ReadFile(path) + if err != nil { + t.Fatalf("io.ReadAll(): %v", err) + } + return string(creds) +} + // proxyConnTest is a test helper to verify the proxy works with a basic connectivity test. func proxyConnTest(t *testing.T, args []string, driver, dsn string) { ctx, cancel := context.WithTimeout(context.Background(), connTestTimeout)