Skip to content

Commit de15073

Browse files
authored
feat: add support for service account impersonation (GoogleCloudPlatform#192)
This is a port of GoogleCloudPlatform/cloud-sql-proxy#1460.
1 parent c53f14e commit de15073

File tree

9 files changed

+140
-25
lines changed

9 files changed

+140
-25
lines changed

.envrc.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ export ALLOYDB_PASS="postgres-password"
44
export ALLOYDB_DB="postgres-db-name"
55

66
export GOOGLE_APPLICATION_CREDENTIALS="path/to/credentials"
7+
8+
# Requires the impersonating IAM principal to have
9+
# roles/iam.serviceAccountTokenCreator
10+
export IMPERSONATED_USER="[email protected]"

.github/workflows/tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,15 @@ jobs:
9090
secrets: |-
9191
ALLOYDB_CONN_NAME:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CONN_NAME
9292
ALLOYDB_CLUSTER_PASS:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CLUSTER_PASS
93+
IMPERSONATED_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/IMPERSONATED_USER
9394
9495
- name: Run tests
9596
env:
9697
ALLOYDB_DB: 'postgres'
9798
ALLOYDB_USER: 'postgres'
9899
ALLOYDB_PASS: '${{ steps.secrets.outputs.ALLOYDB_CLUSTER_PASS }}'
99100
ALLOYDB_CONNECTION_NAME: '${{ steps.secrets.outputs.ALLOYDB_CONN_NAME }}'
101+
IMPERSONATED_USER: '${{ steps.secrets.outputs.IMPERSONATED_USER }}'
100102
# specifying bash shell ensures a failure in a piped process isn't lost by using `set -eo pipefail`
101103
shell: bash
102104
run: |

cmd/root.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ type Command struct {
9494
healthCheck bool
9595
httpAddress string
9696
httpPort string
97+
98+
// impersonationChain is a comma separated list of one or more service
99+
// accounts. The last entry in the chain is the impersonation target. Any
100+
// additional service accounts before the target are delegates. The
101+
// roles/iam.serviceAccountTokenCreator must be configured for each account
102+
// that will be impersonated.
103+
impersonationChain string
97104
}
98105

99106
// Option is a function that configures a Command.
@@ -183,6 +190,9 @@ the maximum time has passed. Defaults to 0s.`)
183190
cmd.PersistentFlags().StringVar(&c.conf.FUSETempDir, "fuse-tmp-dir",
184191
filepath.Join(os.TempDir(), "csql-tmp"),
185192
"Temp dir for Unix sockets created with FUSE")
193+
cmd.PersistentFlags().StringVar(&c.impersonationChain, "impersonate-service-account", "",
194+
`Comma separated list of service accounts to impersonate. Last value
195+
+is the target account.`)
186196

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

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

314+
if cmd.impersonationChain != "" {
315+
accts := strings.Split(cmd.impersonationChain, ",")
316+
conf.ImpersonateTarget = accts[0]
317+
// Assign delegates if the chain is more than one account. Delegation
318+
// goes from last back towards target, e.g., With sa1,sa2,sa3, sa3
319+
// delegates to sa2, which impersonates the target sa1.
320+
if l := len(accts); l > 1 {
321+
for i := l - 1; i > 0; i-- {
322+
conf.ImpersonateDelegates = append(conf.ImpersonateDelegates, accts[i])
323+
}
324+
}
325+
}
326+
301327
var ics []proxy.InstanceConnConfig
302328
for _, a := range args {
303329
// Assume no query params initially

cmd/root_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,19 @@ func TestNewCommandArguments(t *testing.T) {
221221
CredentialsJSON: `{"json":"goes-here"}`,
222222
}),
223223
},
224+
{
225+
desc: "",
226+
args: []string{"--impersonate-service-account",
227+
228+
"projects/proj/locations/region/clusters/clust/instances/inst"},
229+
want: withDefaults(&proxy.Config{
230+
ImpersonateTarget: "[email protected]",
231+
ImpersonateDelegates: []string{
232+
233+
234+
},
235+
}),
236+
},
224237
}
225238

226239
for _, tc := range tcs {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
go.uber.org/zap v1.24.0
1414
golang.org/x/oauth2 v0.2.0
1515
golang.org/x/sys v0.3.0
16+
google.golang.org/api v0.103.0
1617
)
1718

1819
require (
@@ -57,7 +58,6 @@ require (
5758
golang.org/x/sync v0.1.0 // indirect
5859
golang.org/x/text v0.4.0 // indirect
5960
golang.org/x/time v0.2.0 // indirect
60-
google.golang.org/api v0.103.0 // indirect
6161
google.golang.org/appengine v1.6.7 // indirect
6262
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
6363
google.golang.org/grpc v1.51.0 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,6 +1979,7 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
19791979
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
19801980
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
19811981
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
1982+
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
19821983
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
19831984
google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
19841985
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=

internal/proxy/proxy.go

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import (
3131
"github.com/GoogleCloudPlatform/alloydb-auth-proxy/alloydb"
3232
"github.com/GoogleCloudPlatform/alloydb-auth-proxy/internal/gcloud"
3333
"golang.org/x/oauth2"
34+
"google.golang.org/api/impersonate"
35+
"google.golang.org/api/option"
36+
"google.golang.org/api/sqladmin/v1"
3437
)
3538

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

110+
// ImpersonateTarget is the service account to impersonate. The IAM
111+
// principal doing the impersonation must have the
112+
// roles/iam.serviceAccountTokenCreator role.
113+
ImpersonateTarget string
114+
// ImpersonateDelegates are the intermediate service accounts through which
115+
// the impersonation is achieved. Each delegate must have the
116+
// roles/iam.serviceAccountTokenCreator role.
117+
ImpersonateDelegates []string
118+
107119
// StructuredLogs sets all output to use JSON in the LogEntry format.
108120
// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
109121
StructuredLogs bool
110122
}
111123

112-
// DialerOptions builds appropriate list of options from the Config
113-
// values for use by alloydbconn.NewClient()
114-
func (c *Config) DialerOptions(l alloydb.Logger) ([]alloydbconn.Option, error) {
115-
opts := []alloydbconn.Option{
116-
alloydbconn.WithUserAgent(c.UserAgent),
124+
func (c *Config) credentialsOpt(l alloydb.Logger) (alloydbconn.Option, error) {
125+
// If service account impersonation is configured, set up an impersonated
126+
// credentials token source.
127+
if c.ImpersonateTarget != "" {
128+
var iopts []option.ClientOption
129+
switch {
130+
case c.Token != "":
131+
l.Infof("Impersonating service account with OAuth2 token")
132+
iopts = append(iopts, option.WithTokenSource(
133+
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
134+
))
135+
case c.CredentialsFile != "":
136+
l.Infof("Impersonating service account with the credentials file at %q", c.CredentialsFile)
137+
iopts = append(iopts, option.WithCredentialsFile(c.CredentialsFile))
138+
case c.CredentialsJSON != "":
139+
l.Infof("Impersonating service account with JSON credentials environment variable")
140+
iopts = append(iopts, option.WithCredentialsJSON([]byte(c.CredentialsJSON)))
141+
case c.GcloudAuth:
142+
l.Infof("Impersonating service account with gcloud user credentials")
143+
ts, err := gcloud.TokenSource()
144+
if err != nil {
145+
return nil, err
146+
}
147+
iopts = append(iopts, option.WithTokenSource(ts))
148+
default:
149+
l.Infof("Impersonating service account with Application Default Credentials")
150+
}
151+
ts, err := impersonate.CredentialsTokenSource(
152+
context.Background(),
153+
impersonate.CredentialsConfig{
154+
TargetPrincipal: c.ImpersonateTarget,
155+
Delegates: c.ImpersonateDelegates,
156+
Scopes: []string{sqladmin.SqlserviceAdminScope},
157+
},
158+
iopts...,
159+
)
160+
if err != nil {
161+
return nil, err
162+
}
163+
return alloydbconn.WithTokenSource(ts), nil
117164
}
118-
opts = append(opts, alloydbconn.WithAdminAPIEndpoint(c.APIEndpointURL))
165+
// Otherwise, configure credentials as usual.
119166
switch {
120167
case c.Token != "":
121-
l.Infof("Authorizing with the -token flag")
122-
opts = append(opts, alloydbconn.WithTokenSource(
168+
l.Infof("Authorizing with OAuth2 token")
169+
return alloydbconn.WithTokenSource(
123170
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
124-
))
171+
), nil
125172
case c.CredentialsFile != "":
126173
l.Infof("Authorizing with the credentials file at %q", c.CredentialsFile)
127-
opts = append(opts, alloydbconn.WithCredentialsFile(
128-
c.CredentialsFile,
129-
))
174+
return alloydbconn.WithCredentialsFile(c.CredentialsFile), nil
175+
case c.CredentialsJSON != "":
176+
l.Infof("Authorizing with JSON credentials environment variable")
177+
return alloydbconn.WithCredentialsJSON([]byte(c.CredentialsJSON)), nil
130178
case c.GcloudAuth:
131179
l.Infof("Authorizing with gcloud user credentials")
132180
ts, err := gcloud.TokenSource()
133181
if err != nil {
134182
return nil, err
135183
}
136-
opts = append(opts, alloydbconn.WithTokenSource(ts))
137-
case c.CredentialsJSON != "":
138-
l.Infof("Authorizing with JSON credentials environment variable")
139-
opts = append(opts, alloydbconn.WithCredentialsJSON(
140-
[]byte(c.CredentialsJSON),
141-
))
184+
return alloydbconn.WithTokenSource(ts), nil
142185
default:
143186
l.Infof("Authorizing with Application Default Credentials")
187+
// Return no-op options to avoid having to handle nil in caller code
188+
return alloydbconn.WithOptions(), nil
189+
}
190+
}
191+
192+
// DialerOptions builds appropriate list of options from the Config
193+
// values for use by alloydbconn.NewClient()
194+
func (c *Config) DialerOptions(l alloydb.Logger) ([]alloydbconn.Option, error) {
195+
opts := []alloydbconn.Option{
196+
alloydbconn.WithUserAgent(c.UserAgent),
197+
}
198+
co, err := c.credentialsOpt(l)
199+
if err != nil {
200+
return nil, err
201+
}
202+
opts = append(opts, co)
203+
204+
if c.APIEndpointURL != "" {
205+
opts = append(opts, alloydbconn.WithAdminAPIEndpoint(c.APIEndpointURL))
144206
}
145207

146208
return opts, nil

tests/alloydb_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ import (
2626
)
2727

2828
var (
29-
alloydbConnName = flag.String("alloydb_conn_name", os.Getenv("ALLOYDB_CONNECTION_NAME"), "AlloyDB instance connection name, in the form of 'project:region:instance'.")
30-
alloydbUser = flag.String("alloydb_user", os.Getenv("ALLOYDB_USER"), "Name of database user.")
31-
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).")
32-
alloydbDB = flag.String("alloydb_db", os.Getenv("ALLOYDB_DB"), "Name of the database to connect to.")
29+
alloydbConnName = flag.String("alloydb_conn_name", os.Getenv("ALLOYDB_CONNECTION_NAME"), "AlloyDB instance connection name, in the form of 'project:region:instance'.")
30+
alloydbUser = flag.String("alloydb_user", os.Getenv("ALLOYDB_USER"), "Name of database user.")
31+
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).")
32+
alloydbDB = flag.String("alloydb_db", os.Getenv("ALLOYDB_DB"), "Name of the database to connect to.")
33+
impersonatedUser = flag.String(
34+
"impersonated_user",
35+
os.Getenv("IMPERSONATED_USER"),
36+
"Name of the service account that supports impersonation (impersonator must have roles/iam.serviceAccountTokenCreator)",
37+
)
3338
)
3439

3540
func requirePostgresVars(t *testing.T) {

tests/connection_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ const connTestTimeout = time.Minute
3434
func removeAuthEnvVar(t *testing.T, wantToken bool) (*oauth2.Token, string, func()) {
3535
var tok *oauth2.Token
3636
if wantToken {
37-
ts, err := google.DefaultTokenSource(context.Background())
37+
ts, err := google.DefaultTokenSource(context.Background(),
38+
"https://www.googleapis.com/auth/cloud-platform",
39+
)
3840
if err != nil {
3941
t.Errorf("failed to resolve token source: %v", err)
4042
}

0 commit comments

Comments
 (0)