Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for service account impersonation #192

Merged
merged 5 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ export ALLOYDB_PASS="postgres-password"
export ALLOYDB_DB="postgres-db-name"

export GOOGLE_APPLICATION_CREDENTIALS="path/to/credentials"

# Requires the impersonating IAM principal to have
# roles/iam.serviceAccountTokenCreator
export IMPERSONATED_USER="[email protected]"
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ jobs:
secrets: |-
ALLOYDB_CONN_NAME:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CONN_NAME
ALLOYDB_CLUSTER_PASS:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CLUSTER_PASS
IMPERSONATED_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/IMPERSONATED_USER

- name: Run tests
env:
ALLOYDB_DB: 'postgres'
ALLOYDB_USER: 'postgres'
ALLOYDB_PASS: '${{ steps.secrets.outputs.ALLOYDB_CLUSTER_PASS }}'
ALLOYDB_CONNECTION_NAME: '${{ steps.secrets.outputs.ALLOYDB_CONN_NAME }}'
IMPERSONATED_USER: '${{ steps.secrets.outputs.IMPERSONATED_USER }}'
# specifying bash shell ensures a failure in a piped process isn't lost by using `set -eo pipefail`
shell: bash
run: |
Expand Down
28 changes: 27 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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 @@ -183,6 +190,9 @@ the maximum time has passed. Defaults to 0s.`)
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.`)

cmd.PersistentFlags().StringVar(&c.telemetryProject, "telemetry-project", "",
"Enable Cloud Monitoring and Cloud Trace integration with the provided project ID.")
Expand Down Expand Up @@ -274,7 +284,10 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error {
if userHasSet("alloydbadmin-api-endpoint") {
_, err := url.Parse(conf.APIEndpointURL)
if err != nil {
return newBadCommandError(fmt.Sprintf("provided value for --alloydbadmin-api-endpoint is not a valid url, %v", conf.APIEndpointURL))
return newBadCommandError(fmt.Sprintf(
"provided value for --alloydbadmin-api-endpoint is not a valid url, %v",
conf.APIEndpointURL,
))
}

// Remove trailing '/' if included
Expand All @@ -298,6 +311,19 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error {
cmd.logger.Infof("Ignoring --disable-traces as --telemetry-project was not set")
}

if cmd.impersonationChain != "" {
accts := strings.Split(cmd.impersonationChain, ",")
conf.ImpersonateTarget = accts[0]
// Assign delegates if the chain is more than one account. Delegation
// goes from last back towards target, e.g., With sa1,sa2,sa3, sa3
// delegates to sa2, which impersonates the target sa1.
if l := len(accts); l > 1 {
for i := l - 1; i > 0; i-- {
conf.ImpersonateDelegates = append(conf.ImpersonateDelegates, accts[i])
}
}
}

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 @@ -221,6 +221,19 @@ func TestNewCommandArguments(t *testing.T) {
CredentialsJSON: `{"json":"goes-here"}`,
}),
},
{
desc: "",
args: []string{"--impersonate-service-account",
"[email protected],[email protected],[email protected]",
"projects/proj/locations/region/clusters/clust/instances/inst"},
want: withDefaults(&proxy.Config{
ImpersonateTarget: "[email protected]",
ImpersonateDelegates: []string{
"[email protected]",
"[email protected]",
},
}),
},
}

for _, tc := range tcs {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
go.uber.org/zap v1.24.0
golang.org/x/oauth2 v0.2.0
golang.org/x/sys v0.3.0
google.golang.org/api v0.103.0
)

require (
Expand Down Expand Up @@ -57,7 +58,6 @@ require (
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.2.0 // indirect
google.golang.org/api v0.103.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
google.golang.org/grpc v1.51.0 // indirect
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,7 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
Expand Down
98 changes: 80 additions & 18 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import (
"github.com/GoogleCloudPlatform/alloydb-auth-proxy/alloydb"
"github.com/GoogleCloudPlatform/alloydb-auth-proxy/internal/gcloud"
"golang.org/x/oauth2"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
"google.golang.org/api/sqladmin/v1"
)

// InstanceConnConfig holds the configuration for an individual instance
Expand Down Expand Up @@ -104,43 +107,102 @@ type Config struct {
// regardless of any open connections.
WaitOnClose time.Duration

// 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
enocom marked this conversation as resolved.
Show resolved Hide resolved

// 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
}

// DialerOptions builds appropriate list of options from the Config
// values for use by alloydbconn.NewClient()
func (c *Config) DialerOptions(l alloydb.Logger) ([]alloydbconn.Option, error) {
opts := []alloydbconn.Option{
alloydbconn.WithUserAgent(c.UserAgent),
func (c *Config) credentialsOpt(l alloydb.Logger) (alloydbconn.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 != "":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this switch statement from line 130-150 are much identical to lines 166-189. Could you somehow
combine those into a separate function?

iopts := c.withCredentialSource(l) // switch statement in here
if c.ImpersonateTarget != "" {
   ts, err := impersonate.CredentialsTokenSource( ... iopts)
   // ... error handling, etc
   return alloydbconn.WithTokenSource(ts)
}
return iopts

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are making significant changes to this PR we should make sure to update the Cloud SQL go connector as well

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's fix this in the v2 proxy and port the change here.

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.SqlserviceAdminScope},
},
iopts...,
)
if err != nil {
return nil, err
}
return alloydbconn.WithTokenSource(ts), nil
}
opts = append(opts, alloydbconn.WithAdminAPIEndpoint(c.APIEndpointURL))
// Otherwise, configure credentials as usual.
switch {
case c.Token != "":
l.Infof("Authorizing with the -token flag")
opts = append(opts, alloydbconn.WithTokenSource(
l.Infof("Authorizing with OAuth2 token")
return alloydbconn.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, alloydbconn.WithCredentialsFile(
c.CredentialsFile,
))
return alloydbconn.WithCredentialsFile(c.CredentialsFile), nil
case c.CredentialsJSON != "":
l.Infof("Authorizing with JSON credentials environment variable")
return alloydbconn.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, alloydbconn.WithTokenSource(ts))
case c.CredentialsJSON != "":
l.Infof("Authorizing with JSON credentials environment variable")
opts = append(opts, alloydbconn.WithCredentialsJSON(
[]byte(c.CredentialsJSON),
))
return alloydbconn.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 alloydbconn.WithOptions(), nil
}
}

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

if c.APIEndpointURL != "" {
opts = append(opts, alloydbconn.WithAdminAPIEndpoint(c.APIEndpointURL))
}

return opts, nil
Expand Down
13 changes: 9 additions & 4 deletions tests/alloydb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ import (
)

var (
alloydbConnName = flag.String("alloydb_conn_name", os.Getenv("ALLOYDB_CONNECTION_NAME"), "AlloyDB instance connection name, in the form of 'project:region:instance'.")
alloydbUser = flag.String("alloydb_user", os.Getenv("ALLOYDB_USER"), "Name of database user.")
alloydbPass = flag.String("alloydb_pass", os.Getenv("ALLOYDB_PASS"), "Password for the database user; be careful when entering a password on the command line (it may go into your terminal's history).")
alloydbDB = flag.String("alloydb_db", os.Getenv("ALLOYDB_DB"), "Name of the database to connect to.")
alloydbConnName = flag.String("alloydb_conn_name", os.Getenv("ALLOYDB_CONNECTION_NAME"), "AlloyDB instance connection name, in the form of 'project:region:instance'.")
alloydbUser = flag.String("alloydb_user", os.Getenv("ALLOYDB_USER"), "Name of database user.")
alloydbPass = flag.String("alloydb_pass", os.Getenv("ALLOYDB_PASS"), "Password for the database user; be careful when entering a password on the command line (it may go into your terminal's history).")
alloydbDB = flag.String("alloydb_db", os.Getenv("ALLOYDB_DB"), "Name of the database to connect to.")
impersonatedUser = flag.String(
"impersonated_user",
os.Getenv("IMPERSONATED_USER"),
"Name of the service account that supports impersonation (impersonator must have roles/iam.serviceAccountTokenCreator)",
)
)

func requirePostgresVars(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion tests/connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ const connTestTimeout = time.Minute
func removeAuthEnvVar(t *testing.T, wantToken bool) (*oauth2.Token, string, func()) {
var tok *oauth2.Token
if wantToken {
ts, err := google.DefaultTokenSource(context.Background())
ts, err := google.DefaultTokenSource(context.Background(),
"https://www.googleapis.com/auth/cloud-platform",
)
if err != nil {
t.Errorf("failed to resolve token source: %v", err)
}
Expand Down