feat(option): support service account impersonation (#625)
This adds support for service account impersonation with the use
of a new client option. This implementation only supports
service-account/user-credential impersonating another service
account. This does not cover the use-case that involves
impersonating a service account as an admin user with domain wide
delegation.
Fixes: #378
diff --git a/idtoken/idtoken.go b/idtoken/idtoken.go
index 67c8965..be52f3b 100644
--- a/idtoken/idtoken.go
+++ b/idtoken/idtoken.go
@@ -78,6 +78,9 @@
if ds.TokenSource != nil {
return nil, fmt.Errorf("idtoken: option.WithTokenSource not supported")
}
+ if ds.ImpersonationConfig != nil {
+ return nil, fmt.Errorf("idtoken: option.WithImpersonatedCredentials not supported")
+ }
return newTokenSource(ctx, audience, &ds)
}
diff --git a/integration-tests/impersonate/impersonate_test.go b/integration-tests/impersonate/impersonate_test.go
new file mode 100644
index 0000000..efbf920
--- /dev/null
+++ b/integration-tests/impersonate/impersonate_test.go
@@ -0,0 +1,91 @@
+// Copyright 2020 Google LLC.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build integration
+
+package impersonate
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "os"
+ "testing"
+ "time"
+
+ "google.golang.org/api/option"
+
+ "google.golang.org/api/storage/v1"
+)
+
+var (
+ // envReaderCredentialFile points to a service accountthat is a "Service
+ // Account Token Creator" on envReaderSA.
+ envBaseSACredentialFile = "API_GO_CLIENT_IMPERSONATE_BASE"
+ // envUserCredentialFile points to a user credential that is a "Service
+ // Account Token Creator" on envReaderSA.
+ envUserCredentialFile = "API_GO_CLIENT_IMPERSONATE_USER"
+ // envReaderCredentialFile points to a service account that is a "Storage
+ // Object Reader" and is a "Service Account Token Creator" on envWriterSA.
+ envReaderCredentialFile = "API_GO_CLIENT_IMPERSONATE_READER"
+ // envReaderSA is the name of the reader service account.
+ envReaderSA = "API_GO_CLIENT_IMPERSONATE_READER_SA"
+ // envWriterSA is the name of the writer service account. This service
+ // account has been granted roles/serviceusage.serviceUsageConsumer.
+ envWriterSA = "API_GO_CLIENT_IMPERSONATE_WRITER_SA"
+ // envProjectID is a project that hosts a GCS bucket.
+ envProjectID = "GOOGLE_CLOUD_PROJECT"
+)
+
+func init() {
+ rand.Seed(time.Now().UnixNano())
+}
+
+func TestImpersonatedCredentials(t *testing.T) {
+ ctx := context.Background()
+ projID := os.Getenv(envProjectID)
+ writerSA := os.Getenv(envWriterSA)
+ tests := []struct {
+ name string
+ baseSALocation string
+ delgates []string
+ }{
+ {
+ name: "SA -> SA",
+ baseSALocation: os.Getenv(envReaderCredentialFile),
+ delgates: []string{},
+ },
+ {
+ name: "SA -> Delegate -> SA",
+ baseSALocation: os.Getenv(envBaseSACredentialFile),
+ delgates: []string{os.Getenv(envReaderSA)},
+ },
+ {
+ name: "User Credential -> Delegate -> SA",
+ baseSALocation: os.Getenv(envUserCredentialFile),
+ delgates: []string{os.Getenv(envReaderSA)},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc, err := storage.NewService(ctx,
+ option.WithCredentialsFile(tt.baseSALocation),
+ option.ImpersonateCredentials(writerSA, tt.delgates...),
+ )
+ if err != nil {
+ t.Fatalf("failed to create client: %v", err)
+ }
+ bucketName := fmt.Sprintf("%s-%d", projID, rand.Int63())
+ if _, err := svc.Buckets.Insert(projID, &storage.Bucket{
+ Name: bucketName,
+ }).Do(); err != nil {
+ t.Fatalf("error creating bucket: %v", err)
+ }
+ if err := svc.Buckets.Delete(bucketName).Do(); err != nil {
+ t.Fatalf("unable to cleanup bucket %q: %v", bucketName, err)
+ }
+ })
+ }
+}
diff --git a/internal/creds.go b/internal/creds.go
index 75e9445..dc6d50e 100644
--- a/internal/creds.go
+++ b/internal/creds.go
@@ -11,6 +11,7 @@
"io/ioutil"
"golang.org/x/oauth2"
+ "google.golang.org/api/internal/impersonate"
"golang.org/x/oauth2/google"
)
@@ -18,6 +19,17 @@
// Creds returns credential information obtained from DialSettings, or if none, then
// it returns default credential information.
func Creds(ctx context.Context, ds *DialSettings) (*google.Credentials, error) {
+ creds, err := baseCreds(ctx, ds)
+ if err != nil {
+ return nil, err
+ }
+ if ds.ImpersonationConfig != nil {
+ return impersonateCredentials(ctx, creds, ds)
+ }
+ return creds, nil
+}
+
+func baseCreds(ctx context.Context, ds *DialSettings) (*google.Credentials, error) {
if ds.Credentials != nil {
return ds.Credentials, nil
}
@@ -103,3 +115,17 @@
}
return v.QuotaProject
}
+
+func impersonateCredentials(ctx context.Context, creds *google.Credentials, ds *DialSettings) (*google.Credentials, error) {
+ if len(ds.ImpersonationConfig.Scopes) == 0 {
+ ds.ImpersonationConfig.Scopes = ds.Scopes
+ }
+ ts, err := impersonate.TokenSource(ctx, creds.TokenSource, ds.ImpersonationConfig)
+ if err != nil {
+ return nil, err
+ }
+ return &google.Credentials{
+ TokenSource: ts,
+ ProjectID: creds.ProjectID,
+ }, nil
+}
diff --git a/internal/impersonate/impersonate.go b/internal/impersonate/impersonate.go
new file mode 100644
index 0000000..b465bbc
--- /dev/null
+++ b/internal/impersonate/impersonate.go
@@ -0,0 +1,128 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package impersonate is used to impersonate Google Credentials.
+package impersonate
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// Config for generating impersonated credentials.
+type Config struct {
+ // Target is the service account to impersonate. Required.
+ Target string
+ // Scopes the impersonated credential should have. Required.
+ Scopes []string
+ // Delegates are the service accounts in a delegation chain. Each service
+ // account must be granted roles/iam.serviceAccountTokenCreator on the next
+ // service account in the chain. Optional.
+ Delegates []string
+}
+
+// TokenSource returns an impersonated TokenSource configured with the provided
+// config using ts as the base credential provider for making requests.
+func TokenSource(ctx context.Context, ts oauth2.TokenSource, config *Config) (oauth2.TokenSource, error) {
+ if len(config.Scopes) == 0 {
+ return nil, fmt.Errorf("impersonate: scopes must be provided")
+ }
+ its := impersonatedTokenSource{
+ ctx: ctx,
+ ts: ts,
+ name: formatIAMServiceAccountName(config.Target),
+ // Default to the longest acceptable value of one hour as the token will
+ // be refreshed automatically.
+ lifetime: "3600s",
+ }
+
+ its.delegates = make([]string, len(config.Delegates))
+ for i, v := range config.Delegates {
+ its.delegates[i] = formatIAMServiceAccountName(v)
+ }
+ its.scopes = make([]string, len(config.Scopes))
+ copy(its.scopes, config.Scopes)
+
+ return oauth2.ReuseTokenSource(nil, its), nil
+}
+
+func formatIAMServiceAccountName(name string) string {
+ return fmt.Sprintf("projects/-/serviceAccounts/%s", name)
+}
+
+type generateAccessTokenReq struct {
+ Delegates []string `json:"delegates,omitempty"`
+ Lifetime string `json:"lifetime,omitempty"`
+ Scope []string `json:"scope,omitempty"`
+}
+
+type generateAccessTokenResp struct {
+ AccessToken string `json:"accessToken"`
+ ExpireTime string `json:"expireTime"`
+}
+
+type impersonatedTokenSource struct {
+ ctx context.Context
+ ts oauth2.TokenSource
+
+ name string
+ lifetime string
+ scopes []string
+ delegates []string
+}
+
+// Token returns an impersonated Token.
+func (i impersonatedTokenSource) Token() (*oauth2.Token, error) {
+ hc := oauth2.NewClient(i.ctx, i.ts)
+ reqBody := generateAccessTokenReq{
+ Delegates: i.delegates,
+ Lifetime: i.lifetime,
+ Scope: i.scopes,
+ }
+ b, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err)
+ }
+ url := fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", i.name)
+ req, err := http.NewRequest("POST", url, bytes.NewReader(b))
+ if err != nil {
+ return nil, fmt.Errorf("impersonate: unable to create request: %v", err)
+ }
+ req = req.WithContext(i.ctx)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := hc.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err)
+ }
+ defer resp.Body.Close()
+ body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, fmt.Errorf("impersonate: unable to read body: %v", err)
+ }
+ if c := resp.StatusCode; c < 200 || c > 299 {
+ return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
+ }
+
+ var accessTokenResp generateAccessTokenResp
+ if err := json.Unmarshal(body, &accessTokenResp); err != nil {
+ return nil, fmt.Errorf("impersonate: unable to parse response: %v", err)
+ }
+ expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
+ if err != nil {
+ return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err)
+ }
+ return &oauth2.Token{
+ AccessToken: accessTokenResp.AccessToken,
+ Expiry: expiry,
+ }, nil
+}
diff --git a/internal/settings.go b/internal/settings.go
index 3b779c2..26259b8 100644
--- a/internal/settings.go
+++ b/internal/settings.go
@@ -12,6 +12,7 @@
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
+ "google.golang.org/api/internal/impersonate"
"google.golang.org/grpc"
)
@@ -39,6 +40,7 @@
ClientCertSource func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
CustomClaims map[string]interface{}
SkipValidation bool
+ ImpersonationConfig *impersonate.Config
// Google API system parameters. For more information please read:
// https://cloud.google.com/apis/docs/system-parameters
@@ -105,6 +107,8 @@
if ds.ClientCertSource != nil && (ds.GRPCConn != nil || ds.GRPCConnPool != nil || ds.GRPCConnPoolSize != 0 || ds.GRPCDialOpts != nil) {
return errors.New("WithClientCertSource is currently only supported for HTTP. gRPC settings are incompatible")
}
-
+ if ds.ImpersonationConfig != nil && len(ds.ImpersonationConfig.Scopes) == 0 && len(ds.Scopes) == 0 {
+ return errors.New("WithImpersonatedCredentials requires scopes being provided")
+ }
return nil
}
diff --git a/internal/settings_test.go b/internal/settings_test.go
index 818e15b..8b6e6d3 100644
--- a/internal/settings_test.go
+++ b/internal/settings_test.go
@@ -10,6 +10,7 @@
"net/http"
"testing"
+ "google.golang.org/api/internal/impersonate"
"google.golang.org/grpc"
"golang.org/x/oauth2"
@@ -35,6 +36,8 @@
// the check feasible.
{NoAuth: true, Scopes: []string{"s"}},
{ClientCertSource: dummyGetClientCertificate},
+ {ImpersonationConfig: &impersonate.Config{Scopes: []string{"x"}}},
+ {ImpersonationConfig: &impersonate.Config{}, Scopes: []string{"x"}},
} {
err := ds.Validate()
if err != nil {
@@ -63,6 +66,7 @@
{ClientCertSource: dummyGetClientCertificate, GRPCConnPool: struct{ ConnPool }{}},
{ClientCertSource: dummyGetClientCertificate, GRPCDialOpts: []grpc.DialOption{grpc.WithInsecure()}},
{ClientCertSource: dummyGetClientCertificate, GRPCConnPoolSize: 1},
+ {ImpersonationConfig: &impersonate.Config{}},
} {
err := ds.Validate()
if err == nil {
diff --git a/option/option.go b/option/option.go
index b7c40d6..686476f 100644
--- a/option/option.go
+++ b/option/option.go
@@ -11,6 +11,7 @@
"golang.org/x/oauth2"
"google.golang.org/api/internal"
+ "google.golang.org/api/internal/impersonate"
"google.golang.org/grpc"
)
@@ -269,3 +270,57 @@
func (w withClientCertSource) Apply(o *internal.DialSettings) {
o.ClientCertSource = w.s
}
+
+// ImpersonateCredentials returns a ClientOption that will impersonate the
+// target service account.
+//
+// In order to impersonate the target service account
+// the base service account must have the Service Account Token Creator role,
+// roles/iam.serviceAccountTokenCreator, on the target service account.
+// See https://cloud.google.com/iam/docs/understanding-service-accounts.
+//
+// Optionally, delegates can be used during impersonation if the base service
+// account lacks the token creator role on the target. When using delegates,
+// each service account must be granted roles/iam.serviceAccountTokenCreator
+// on the next service account in the chain.
+//
+// For example, if a base service account of SA1 is trying to impersonate target
+// service account SA2 while using delegate service accounts DSA1 and DSA2,
+// the following must be true:
+//
+// 1. Base service account SA1 has roles/iam.serviceAccountTokenCreator on
+// DSA1.
+// 2. DSA1 has roles/iam.serviceAccountTokenCreator on DSA2.
+// 3. DSA2 has roles/iam.serviceAccountTokenCreator on target SA2.
+//
+// The resulting impersonated credential will either have the default scopes of
+// the client being instantiating or the scopes from WithScopes if provided.
+// Scopes are required for creating impersonated credentials, so if this option
+// is used while not using a NewClient/NewService function, WithScopes must also
+// be explicitly passed in as well.
+//
+// If the base credential is an authorized user and not a service account, or if
+// the option WithQuotaProject is set, the target service account must have a
+// role that grants the serviceusage.services.use permission such as
+// roles/serviceusage.serviceUsageConsumer.
+//
+// This is an EXPERIMENTAL API and may be changed or removed in the future.
+func ImpersonateCredentials(target string, delegates ...string) ClientOption {
+ return impersonateServiceAccount{
+ target: target,
+ delegates: delegates,
+ }
+}
+
+type impersonateServiceAccount struct {
+ target string
+ delegates []string
+}
+
+func (i impersonateServiceAccount) Apply(o *internal.DialSettings) {
+ o.ImpersonationConfig = &impersonate.Config{
+ Target: i.target,
+ }
+ o.ImpersonationConfig.Delegates = make([]string, len(i.delegates))
+ copy(o.ImpersonationConfig.Delegates, i.delegates)
+}