Skip to content

Commit

Permalink
feat: add support for impersonation
Browse files Browse the repository at this point in the history
Fixes #417
  • Loading branch information
enocom committed Oct 12, 2022
1 parent 049d0be commit bf71026
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 130 deletions.
4 changes: 4 additions & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ export SQLSERVER_PASS="sqlserver-password"
export SQLSERVER_DB="sqlserver-db-name"

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json

# Requires the impersonating IAM principal to have
# roles/iam.serviceAccountTokenCreator
export IMPERSONATED_USER="some-user-with-db-access@example.com"
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ jobs:
SQLSERVER_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER
SQLSERVER_PASS:${{ secrets.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS
SQLSERVER_DB:${{ secrets.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_DB
IMPERSONATED_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/IMPERSONATED_USER
- name: Enable fuse config (Linux)
if: runner.os == 'Linux'
Expand All @@ -130,6 +131,7 @@ jobs:
SQLSERVER_USER: '${{ steps.secrets.outputs.SQLSERVER_USER }}'
SQLSERVER_PASS: '${{ steps.secrets.outputs.SQLSERVER_PASS }}'
SQLSERVER_DB: '${{ steps.secrets.outputs.SQLSERVER_DB }}'
IMPERSONATED_USER: '${{ steps.secrets.outputs.IMPERSONATED_USER }}'
TMPDIR: "/tmp"
TMP: '${{ runner.temp }}'
# specifying bash shell ensures a failure in a piped process isn't lost by using `set -eo pipefail`
Expand Down
25 changes: 24 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ type Command struct {
healthCheck bool
httpAddress string
httpPort string

// impersonationChain is a comma separated list of one or more service
// accounts. The last entry in the chain is the impersonation target. Any
// additional service accounts before the target are delegates. The
// roles/iam.serviceAccountTokenCreator must be configured for each account
// that will be impersonated.
impersonationChain string
}

// Option is a function that configures a Command.
Expand Down Expand Up @@ -253,6 +260,9 @@ https://cloud.google.com/storage/docs/requester-pays`)
cmd.PersistentFlags().StringVar(&c.conf.FUSETempDir, "fuse-tmp-dir",
filepath.Join(os.TempDir(), "csql-tmp"),
"Temp dir for Unix sockets created with FUSE")
cmd.PersistentFlags().StringVar(&c.impersonationChain, "impersonate-service-account", "",
`Comma separated list of service accounts to impersonate. Last value
is the target account.`)

// Global and per instance flags
cmd.PersistentFlags().StringVarP(&c.conf.Addr, "address", "a", "127.0.0.1",
Expand Down Expand Up @@ -338,7 +348,10 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error {
if userHasSet("sqladmin-api-endpoint") && conf.APIEndpointURL != "" {
_, err := url.Parse(conf.APIEndpointURL)
if err != nil {
return newBadCommandError(fmt.Sprintf("the value provided for --sqladmin-api-endpoint is not a valid URL, %v", conf.APIEndpointURL))
return newBadCommandError(fmt.Sprintf(
"the value provided for --sqladmin-api-endpoint is not a valid URL, %v",
conf.APIEndpointURL,
))
}

// add a trailing '/' if omitted
Expand All @@ -347,6 +360,16 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error {
}
}

if cmd.impersonationChain != "" {
accts := strings.Split(cmd.impersonationChain, ",")
conf.ImpersonateTarget = accts[0]
// Assign delegates if the chain is more than one account.
if l := len(accts); l > 1 {
conf.ImpersonateTarget = accts[l-1]
conf.ImpersonateDelegates = accts[:l-1]
}
}

var ics []proxy.InstanceConnConfig
for _, a := range args {
// Assume no query params initially
Expand Down
13 changes: 13 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,19 @@ func TestNewCommandArguments(t *testing.T) {
QuotaProject: "proj",
}),
},
{
desc: "",
args: []string{"--impersonate-service-account",
"sv1@developer.gserviceaccount.com,sv2@developer.gserviceaccount.com,sv3@developer.gserviceaccount.com",
"proj:region:inst"},
want: withDefaults(&proxy.Config{
ImpersonateTarget: "sv3@developer.gserviceaccount.com",
ImpersonateDelegates: []string{
"sv1@developer.gserviceaccount.com",
"sv2@developer.gserviceaccount.com",
},
}),
},
}

for _, tc := range tcs {
Expand Down
93 changes: 78 additions & 15 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import (
"github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/cloudsql"
"github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/gcloud"
"golang.org/x/oauth2"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
"google.golang.org/api/sqladmin/v1"
)

var (
Expand Down Expand Up @@ -160,6 +163,15 @@ type Config struct {
// API request quotas.
QuotaProject string

// ImpersonateTarget is the service account to impersonate. The IAM
// principal doing the impersonation must have the
// roles/iam.serviceAccountTokenCreator role.
ImpersonateTarget string
// ImpersonateDelegates are the intermediate service accounts through which
// the impersonation is achieved. Each delegate must have the
// roles/iam.serviceAccountTokenCreator role.
ImpersonateDelegates []string

// StructuredLogs sets all output to use JSON in the LogEntry format.
// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
StructuredLogs bool
Expand Down Expand Up @@ -187,38 +199,89 @@ func (c *Config) DialOptions(i InstanceConnConfig) []cloudsqlconn.DialOption {
return opts
}

// DialerOptions builds appropriate list of options from the Config
// values for use by cloudsqlconn.NewClient()
func (c *Config) DialerOptions(l cloudsql.Logger) ([]cloudsqlconn.Option, error) {
opts := []cloudsqlconn.Option{
cloudsqlconn.WithUserAgent(c.UserAgent),
func (c *Config) credentialsOpt(l cloudsql.Logger) (cloudsqlconn.Option, error) {
// If service account impersonation is configured, set up an impersonated
// credentials token source.
if c.ImpersonateTarget != "" {
var iopts []option.ClientOption
switch {
case c.Token != "":
l.Infof("Impersonating service account with OAuth2 token")
iopts = append(iopts, option.WithTokenSource(
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
))
case c.CredentialsFile != "":
l.Infof("Impersonating service account with the credentials file at %q", c.CredentialsFile)
iopts = append(iopts, option.WithCredentialsFile(c.CredentialsFile))
case c.CredentialsJSON != "":
l.Infof("Impersonating service account with JSON credentials environment variable")
iopts = append(iopts, option.WithCredentialsJSON([]byte(c.CredentialsJSON)))
case c.GcloudAuth:
l.Infof("Impersonating service account with gcloud user credentials")
ts, err := gcloud.TokenSource()
if err != nil {
return nil, err
}
iopts = append(iopts, option.WithTokenSource(ts))
default:
l.Infof("Impersonating service account with Application Default Credentials")
}
ts, err := impersonate.CredentialsTokenSource(
context.Background(),
impersonate.CredentialsConfig{
TargetPrincipal: c.ImpersonateTarget,
Delegates: c.ImpersonateDelegates,
Scopes: []string{
sqladmin.CloudPlatformScope,
sqladmin.SqlserviceAdminScope,
},
},
iopts...,
)
if err != nil {
return nil, err
}
return cloudsqlconn.WithTokenSource(ts), nil
}

// Otherwise, configure credentials as usual.
switch {
case c.Token != "":
l.Infof("Authorizing with the -token flag")
opts = append(opts, cloudsqlconn.WithTokenSource(
l.Infof("Authorizing with OAuth2 token")
return cloudsqlconn.WithTokenSource(
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
))
), nil
case c.CredentialsFile != "":
l.Infof("Authorizing with the credentials file at %q", c.CredentialsFile)
opts = append(opts, cloudsqlconn.WithCredentialsFile(
c.CredentialsFile,
))
return cloudsqlconn.WithCredentialsFile(c.CredentialsFile), nil
case c.CredentialsJSON != "":
l.Infof("Authorizing with JSON credentials environment variable")
opts = append(opts, cloudsqlconn.WithCredentialsJSON(
[]byte(c.CredentialsJSON),
))
return cloudsqlconn.WithCredentialsJSON([]byte(c.CredentialsJSON)), nil
case c.GcloudAuth:
l.Infof("Authorizing with gcloud user credentials")
ts, err := gcloud.TokenSource()
if err != nil {
return nil, err
}
opts = append(opts, cloudsqlconn.WithTokenSource(ts))
return cloudsqlconn.WithTokenSource(ts), nil
default:
l.Infof("Authorizing with Application Default Credentials")
// Return no-op options to avoid having to handle nil in caller code
return cloudsqlconn.WithOptions(), nil
}
}

// DialerOptions builds appropriate list of options from the Config
// values for use by cloudsqlconn.NewClient()
func (c *Config) DialerOptions(l cloudsql.Logger) ([]cloudsqlconn.Option, error) {
opts := []cloudsqlconn.Option{
cloudsqlconn.WithUserAgent(c.UserAgent),
}
co, err := c.credentialsOpt(l)
if err != nil {
return nil, err
}
opts = append(opts, co)

if c.APIEndpointURL != "" {
opts = append(opts, cloudsqlconn.WithAdminAPIEndpoint(c.APIEndpointURL))
Expand Down
9 changes: 9 additions & 0 deletions tests/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
Expand All @@ -34,6 +35,14 @@ import (
"github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/log"
)

var (
impersonatedUser = flag.String(
"impersonated_user",
os.Getenv("IMPERSONATED_USER"),
"Name of the service account that supports impersonation (impersonator must have roles/iam.serviceAccountTokenCreator)",
)
)

// ProxyExec represents an execution of the Cloud SQL proxy.
type ProxyExec struct {
Out io.ReadCloser
Expand Down
5 changes: 4 additions & 1 deletion tests/connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ const connTestTimeout = time.Minute
// 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(), sqladmin.SqlserviceAdminScope)
ts, err := google.DefaultTokenSource(context.Background(),
sqladmin.CloudPlatformScope,
sqladmin.SqlserviceAdminScope,
)
if err != nil {
t.Errorf("failed to resolve token source: %v", err)
}
Expand Down

0 comments on commit bf71026

Please sign in to comment.