Skip to content

Commit

Permalink
feat: add support for gcloud auth (#1166)
Browse files Browse the repository at this point in the history
  • Loading branch information
enocom committed Jun 17, 2022
1 parent bbd490f commit b686477
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 22 deletions.
35 changes: 30 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import (

"cloud.google.com/go/cloudsqlconn"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/cloudsql"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/proxy"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
)

var (
Expand Down Expand Up @@ -110,6 +112,8 @@ 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().BoolVarP(&c.conf.GcloudAuth, "gcloud-auth", "g", false,
"Use gcloud's user configuration to retrieve a token for authentication.")

// Global and per instance flags
cmd.PersistentFlags().StringVarP(&c.conf.Addr, "address", "a", "127.0.0.1",
Expand All @@ -131,19 +135,41 @@ func parseConfig(cmd *cobra.Command, conf *proxy.Config, args []string) error {
return newBadCommandError(fmt.Sprintf("not a valid IP address: %q", conf.Addr))
}

// If both token and credentials file were set, error.
// If more than one auth method is set, error.
if conf.Token != "" && conf.CredentialsFile != "" {
return newBadCommandError("Cannot specify --token and --credentials-file flags at the same time")
}

if conf.Token != "" && conf.GcloudAuth {
return newBadCommandError("Cannot specify --token and --gcloud-auth flags at the same time")
}
if conf.CredentialsFile != "" && conf.GcloudAuth {
return newBadCommandError("Cannot specify --credentials-file and --gcloud-auth flags at the same time")
}
opts := []cloudsqlconn.Option{
cloudsqlconn.WithUserAgent(userAgent),
}
switch {
case conf.Token != "":
cmd.Printf("Authorizing with the -token flag\n")
opts = append(opts, cloudsqlconn.WithTokenSource(
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: conf.Token}),
))
case conf.CredentialsFile != "":
cmd.Printf("Authorizing with the credentials file at %q\n", conf.CredentialsFile)
opts = append(opts, cloudsqlconn.WithCredentialsFile(
conf.CredentialsFile,
))
case conf.GcloudAuth:
cmd.Println("Authorizing with gcloud user credentials")
ts, err := gcloud.TokenSource()
if err != nil {
return err
}
opts = append(opts, cloudsqlconn.WithTokenSource(ts))
default:
cmd.Printf("Authorizing with Application Default Credentials")
cmd.Println("Authorizing with Application Default Credentials")
}
conf.DialerOpts = opts

var ics []proxy.InstanceConnConfig
for _, a := range args {
Expand Down Expand Up @@ -227,9 +253,8 @@ func runSignalWrapper(cmd *Command) error {
// Otherwise, initialize a new one.
d := cmd.conf.Dialer
if d == nil {
opts := append(cmd.conf.DialerOpts(), cloudsqlconn.WithUserAgent(userAgent))
var err error
d, err = cloudsqlconn.NewDialer(ctx, opts...)
d, err = cloudsqlconn.NewDialer(ctx, cmd.conf.DialerOpts...)
if err != nil {
shutdownCh <- fmt.Errorf("error initializing dialer: %v", err)
return
Expand Down
35 changes: 33 additions & 2 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ import (

"cloud.google.com/go/cloudsqlconn"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/proxy"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/testutil"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/spf13/cobra"
)

func TestNewCommandArguments(t *testing.T) {
cleanup := testutil.ConfigureGcloud(t)
defer cleanup()

withDefaults := func(c *proxy.Config) *proxy.Config {
if c.Addr == "" {
c.Addr = "127.0.0.1"
Expand Down Expand Up @@ -133,6 +137,20 @@ func TestNewCommandArguments(t *testing.T) {
CredentialsFile: "/path/to/file",
}),
},
{
desc: "using the gcloud auth flag",
args: []string{"--gcloud-auth", "proj:region:inst"},
want: withDefaults(&proxy.Config{
GcloudAuth: true,
}),
},
{
desc: "using the (short) gcloud auth flag",
args: []string{"-g", "proj:region:inst"},
want: withDefaults(&proxy.Config{
GcloudAuth: true,
}),
},
}

for _, tc := range tcs {
Expand All @@ -152,7 +170,8 @@ func TestNewCommandArguments(t *testing.T) {
t.Fatalf("want error = nil, got = %v", err)
}

if got := c.conf; !cmp.Equal(tc.want, got, cmpopts.IgnoreUnexported(proxy.Config{})) {
opts := cmpopts.IgnoreFields(proxy.Config{}, "DialerOpts")
if got := c.conf; !cmp.Equal(tc.want, got, opts) {
t.Fatalf("want = %#v\ngot = %#v\ndiff = %v", tc.want, got, cmp.Diff(tc.want, got))
}
})
Expand Down Expand Up @@ -201,11 +220,23 @@ func TestNewCommandWithErrors(t *testing.T) {
args: []string{"proj:region:inst?port=hi"},
},
{
desc: "when both token and credentials file is set",
desc: "when both token and credentials file are set",
args: []string{
"--token", "my-token",
"--credentials-file", "/path/to/file", "proj:region:inst"},
},
{
desc: "when both token and gcloud auth are set",
args: []string{
"--token", "my-token",
"--gcloud-auth", "proj:region:inst"},
},
{
desc: "when both gcloud auth and credentials file are set",
args: []string{
"--gcloud-auth",
"--credential-file", "/path/to/file", "proj:region:inst"},
},
}

for _, tc := range tcs {
Expand Down
87 changes: 87 additions & 0 deletions internal/gcloud/gcloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2022 Google LLC
//
// 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 gcloud

import (
"bytes"
"encoding/json"
"fmt"
"runtime"
"time"

"golang.org/x/oauth2"
exec "golang.org/x/sys/execabs"
)

// config represents the credentials returned by `gcloud config config-helper`.
type config struct {
Credential struct {
AccessToken string `json:"access_token"`
TokenExpiry time.Time `json:"token_expiry"`
}
}

func (c *config) Token() *oauth2.Token {
return &oauth2.Token{
AccessToken: c.Credential.AccessToken,
Expiry: c.Credential.TokenExpiry,
}
}

// Path returns the absolute path to the gcloud command. If the command is not
// found it returns an error.
func Path() (string, error) {
g := "gcloud"
if runtime.GOOS == "windows" {
g = g + ".cmd"
}
return exec.LookPath(g)
}

// configHelper implements oauth2.TokenSource via the `gcloud config config-helper` command.
type configHelper struct{}

// Token helps gcloudTokenSource implement oauth2.TokenSource.
func (configHelper) Token() (*oauth2.Token, error) {
gcloudCmd, err := Path()
if err != nil {
return nil, err
}
buf, errbuf := new(bytes.Buffer), new(bytes.Buffer)
cmd := exec.Command(gcloudCmd, "--format", "json", "config", "config-helper", "--min-expiry", "1h")
cmd.Stdout = buf
cmd.Stderr = errbuf

if err := cmd.Run(); err != nil {
err = fmt.Errorf("error reading config: %v; stderr was:\n%v", err, errbuf)
return nil, err
}

c := &config{}
if err := json.Unmarshal(buf.Bytes(), c); err != nil {
return nil, err
}
return c.Token(), nil
}

// TokenSource returns an oauth2.TokenSource backed by the gcloud CLI.
func TokenSource() (oauth2.TokenSource, error) {
h := configHelper{}
tok, err := h.Token()
if err != nil {
return nil, err
}
return oauth2.ReuseTokenSource(tok, h), nil
}
43 changes: 43 additions & 0 deletions internal/gcloud/gcloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2022 Google LLC
//
// 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 gcloud_test

import (
"testing"

"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/testutil"
)

func TestGcloud(t *testing.T) {
if testing.Short() {
t.Skip("skipping gcloud integration tests")
}

cleanup := testutil.ConfigureGcloud(t)
defer cleanup()

// gcloud is now configured. Try to obtain a token from gcloud config
// helper.
ts, err := gcloud.TokenSource()
if err != nil {
t.Fatalf("failed to get token source: %v", err)
}

_, err = ts.Token()
if err != nil {
t.Fatalf("failed to get token: %v", err)
}
}
22 changes: 7 additions & 15 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import (
"cloud.google.com/go/cloudsqlconn"
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/cloudsql"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
)

// InstanceConnConfig holds the configuration for an individual instance
Expand All @@ -48,6 +47,10 @@ type Config struct {
// CredentialsFile is the path to a service account key.
CredentialsFile string

// GcloudAuth set whether to use Gcloud's config helper to retrieve a
// token for authentication.
GcloudAuth bool

// Addr is the address on which to bind all instances.
Addr string

Expand All @@ -62,21 +65,10 @@ type Config struct {
// Dialer specifies the dialer to use when connecting to Cloud SQL
// instances.
Dialer cloudsql.Dialer
}

func (c *Config) DialerOpts() []cloudsqlconn.Option {
var opts []cloudsqlconn.Option
switch {
case c.Token != "":
opts = append(opts, cloudsqlconn.WithTokenSource(
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
))
case c.CredentialsFile != "":
opts = append(opts, cloudsqlconn.WithCredentialsFile(
c.CredentialsFile,
))
}
return opts
// DialerOpts specifies the opts to use when creating a new dialer. This
// value is ignored when a Dialer has been set.
DialerOpts []cloudsqlconn.Option
}

type portConfig struct {
Expand Down
63 changes: 63 additions & 0 deletions internal/testutil/testutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2022 Google LLC
//
// 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 testutil

import (
"bytes"
"io/ioutil"
"os"
"os/exec"
"testing"

"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
)

// ConfigureGcloud configures gcloud using only GOOGLE_APPLICATION_CREDENTIALS
// and stores the resulting configuration in a temporary directory as set by
// CLOUDSDK_CONFIG, which changes the gcloud config directory from the
// default. We use a temporary directory to avoid trampling on any existing
// gcloud config.
func ConfigureGcloud(t *testing.T) func() {
dir, err := ioutil.TempDir("", "cloudsdk*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
os.Setenv("CLOUDSDK_CONFIG", dir)

gcloudCmd, err := gcloud.Path()
if err != nil {
t.Fatal(err)
}

keyFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
if !ok {
t.Fatal("GOOGLE_APPLICATION_CREDENTIALS is not set in the environment")
}
os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")

buf := &bytes.Buffer{}
cmd := exec.Command(gcloudCmd, "auth", "activate-service-account", "--key-file", keyFile)
cmd.Stdout = buf

if err := cmd.Run(); err != nil {
t.Fatalf("failed to active service account. err = %v, message = %v", err, buf.String())
}

return func() {
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", keyFile)
os.Unsetenv("CLOUDSDK_CONFIG")
}

}
Loading

0 comments on commit b686477

Please sign in to comment.