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 impersonation #1460

Merged
merged 4 commits into from
Oct 14, 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 @@ -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="[email protected]"
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The Cloud SQL Auth proxy has support for:
- [Automatic IAM Authentication][iam-auth] (Postgres only)
- Metrics ([Cloud Monitoring][], [Cloud Trace][], and [Prometheus][])
- [HTTP Healthchecks][health-check-example]
- Service account impersonation
- Separate Dialer functionality released as the [Cloud SQL Go Connector][go connector]
- Fully POSIX-compliant flags

Expand Down
28 changes: 27 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.`)
kurtisvg marked this conversation as resolved.
Show resolved Hide resolved

// 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,19 @@ func parseConfig(cmd *Command, conf *proxy.Config, args []string) error {
}
}

if cmd.impersonationChain != "" {
accts := strings.Split(cmd.impersonationChain, ",")
kurtisvg marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -279,6 +279,19 @@ func TestNewCommandArguments(t *testing.T) {
QuotaProject: "proj",
}),
},
{
desc: "",
args: []string{"--impersonate-service-account",
"[email protected],[email protected],[email protected]",
"proj:region:inst"},
want: withDefaults(&proxy.Config{
ImpersonateTarget: "[email protected]",
ImpersonateDelegates: []string{
"[email protected]",
"[email protected]",
},
}),
},
}

for _, tc := range tcs {
Expand Down
90 changes: 75 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,86 @@ 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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't look like it modifies c in anyway. Would it be better to take c as an arg instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I'm in favor of that idea.

Copy link
Member Author

Choose a reason for hiding this comment

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

Looking at this again, I see we're reading a bunch of data from c. So even though we're not modifying it, it's still common to define c as a receiver. Passing it as an argument would basically be a de-sugared version of the same code here.

Copy link
Contributor

Choose a reason for hiding this comment

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

downgrade to nit: but it does seem like a receiver indicates we are doing something to c, where a read only parameter makes clear in the intent is only to create something from c.

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 me try that in a separate PR and we can apply the pattern across the board if we like it.

Copy link
Member Author

Choose a reason for hiding this comment

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

// 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.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: 3 additions & 2 deletions tests/connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import (

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/sqladmin/v1"
)

const connTestTimeout = time.Minute
Expand All @@ -35,7 +34,9 @@ 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(),
"https://www.googleapis.com/auth/cloud-platform",
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need cloud-platform here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Short answer: the impersonation fails without this scope. Longer answer: let me figure out if we can limit this scope still further.

Copy link
Member Author

Choose a reason for hiding this comment

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

)
if err != nil {
t.Errorf("failed to resolve token source: %v", err)
}
Expand Down
Loading