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

Implement Service Account Impersonation #4015

Merged
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
56 changes: 43 additions & 13 deletions third_party/terraform/utils/config.go.erb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import (
sqladmin "google.golang.org/api/sqladmin/v1beta4"
"google.golang.org/api/storage/v1"
"google.golang.org/api/storagetransfer/v1"
"google.golang.org/api/transport"
)

type providerMeta struct {
Expand All @@ -67,16 +68,18 @@ type providerMeta struct {
// Config is the configuration structure used to instantiate the Google
// provider.
type Config struct {
Credentials string
AccessToken string
Project string
BillingProject string
Region string
Zone string
Scopes []string
BatchingConfig *batchingConfig
UserProjectOverride bool
RequestTimeout time.Duration
AccessToken string
Credentials string
ImpersonateServiceAccount string
ImpersonateServiceAccountDelegates []string
Project string
Region string
BillingProject string
Zone string
Scopes []string
BatchingConfig *batchingConfig
UserProjectOverride bool
RequestTimeout time.Duration
// PollInterval is passed to resource.StateChangeConf in common_operation.go
// It controls the interval at which we poll for successful operations
PollInterval time.Duration
Expand Down Expand Up @@ -711,19 +714,29 @@ func (c *Config) NewServiceUsageClient(userAgent string) *serviceusage.Service {

// staticTokenSource is used to be able to identify static token sources without reflection.
type staticTokenSource struct {
oauth2.TokenSource
oauth2.TokenSource
}

func (c *Config) GetCredentials(clientScopes []string) (googleoauth.Credentials, error) {

if c.AccessToken != "" {
contents, _, err := pathOrContents(c.AccessToken)
if err != nil {
return googleoauth.Credentials{}, fmt.Errorf("Error loading access token: %s", err)
}
token := &oauth2.Token{AccessToken: contents}

if c.ImpersonateServiceAccount != "" {
opts := []option.ClientOption{option.WithTokenSource(oauth2.StaticTokenSource(token)), option.ImpersonateCredentials(c.ImpersonateServiceAccount, c.ImpersonateServiceAccountDelegates...), option.WithScopes(clientScopes...)}
creds, err := transport.Creds(context.TODO(), opts...)
if err != nil {
return googleoauth.Credentials{}, err
}
return *creds, nil
}

log.Printf("[INFO] Authenticating using configured Google JSON 'access_token'...")
log.Printf("[INFO] -- Scopes: %s", clientScopes)
token := &oauth2.Token{AccessToken: contents}

return googleoauth.Credentials{
TokenSource: staticTokenSource{oauth2.StaticTokenSource(token)},
Expand All @@ -735,7 +748,14 @@ func (c *Config) GetCredentials(clientScopes []string) (googleoauth.Credentials,
if err != nil {
return googleoauth.Credentials{}, fmt.Errorf("error loading credentials: %s", err)
}

if c.ImpersonateServiceAccount != "" {
upodroid marked this conversation as resolved.
Show resolved Hide resolved
opts := []option.ClientOption{option.WithCredentialsJSON([]byte(contents)), option.ImpersonateCredentials(c.ImpersonateServiceAccount, c.ImpersonateServiceAccountDelegates...), option.WithScopes(clientScopes...)}
creds, err := transport.Creds(context.TODO(), opts...)
if err != nil {
return googleoauth.Credentials{}, err
}
return *creds, nil
}
creds, err := googleoauth.CredentialsFromJSON(c.context, []byte(contents), clientScopes...)
if err != nil {
return googleoauth.Credentials{}, fmt.Errorf("unable to parse credentials from '%s': %s", contents, err)
Expand All @@ -746,6 +766,16 @@ func (c *Config) GetCredentials(clientScopes []string) (googleoauth.Credentials,
return *creds, nil
}

if c.ImpersonateServiceAccount != "" {
opts := option.ImpersonateCredentials(c.ImpersonateServiceAccount, c.ImpersonateServiceAccountDelegates...)
creds, err := transport.Creds(context.TODO(), opts, option.WithScopes(clientScopes...))
if err != nil {
return googleoauth.Credentials{}, err
}
return *creds, nil

}

log.Printf("[INFO] Authenticating using DefaultClient...")
log.Printf("[INFO] -- Scopes: %s", clientScopes)

Expand Down
70 changes: 70 additions & 0 deletions third_party/terraform/utils/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,76 @@ func TestAccConfigLoadValidate_credentials(t *testing.T) {
}
}

func TestAccConfigLoadValidate_impersonated(t *testing.T) {
if os.Getenv(TestEnvVar) == "" {
t.Skip(fmt.Sprintf("Network access not allowed; use %s=1 to enable", TestEnvVar))
}
testAccPreCheck(t)

serviceaccount := multiEnvSearch([]string{"IMPERSONATE_SERVICE_ACCOUNT_ACCTEST"})
creds := getTestCredsFromEnv()
proj := getTestProjectFromEnv()

config := &Config{
upodroid marked this conversation as resolved.
Show resolved Hide resolved
Credentials: creds,
ImpersonateServiceAccount: serviceaccount,
Project: proj,
Region: "us-central1",
}

ConfigureBasePaths(config)

err := config.LoadAndValidate(context.Background())
if err != nil {
t.Fatalf("error: %v", err)
}

_, err = config.NewComputeClient(config.userAgent).Zones.Get(proj, "us-central1-a").Do()
if err != nil {
t.Fatalf("expected API call with loaded config to work, got error: %s", err)
}
}

func TestAccConfigLoadValidate_accessTokenImpersonated(t *testing.T) {
if os.Getenv(TestEnvVar) == "" {
t.Skip(fmt.Sprintf("Network access not allowed; use %s=1 to enable", TestEnvVar))
}
testAccPreCheck(t)

creds := getTestCredsFromEnv()
proj := getTestProjectFromEnv()
serviceaccount := multiEnvSearch([]string{"IMPERSONATE_SERVICE_ACCOUNT_ACCTEST"})

c, err := google.CredentialsFromJSON(context.Background(), []byte(creds), DefaultClientScopes..)
upodroid marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
t.Fatalf("invalid test credentials: %s", err)
}

token, err := c.TokenSource.Token()
if err != nil {
t.Fatalf("Unable to generate test access token: %s", err)
}

config := &Config{
AccessToken: token.AccessToken,
ImpersonateServiceAccount: serviceaccount,
Project: proj,
Region: "us-central1",
}

ConfigureBasePaths(config)

err = config.LoadAndValidate(context.Background())
if err != nil {
t.Fatalf("error: %v", err)
}

_, err = config.NewComputeClient(config.userAgent).Zones.Get(proj, "us-central1-a").Do()
if err != nil {
t.Fatalf("expected API call with loaded config to work, got error: %s", err)
}
}

func TestAccConfigLoadValidate_accessToken(t *testing.T) {
if os.Getenv(TestEnvVar) == "" {
t.Skip(fmt.Sprintf("Network access not allowed; use %s=1 to enable", TestEnvVar))
Expand Down
24 changes: 24 additions & 0 deletions third_party/terraform/utils/provider.go.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ func Provider() *schema.Provider {
}, nil),
ConflictsWith: []string{"credentials"},
},
"impersonate_service_account": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
}, nil),
},

"impersonate_service_account_delegates": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},

"project": &schema.Schema{
Type: schema.TypeString,
Expand Down Expand Up @@ -468,6 +481,9 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData, p *schema.Pr
config.AccessToken = v.(string)
} else if v, ok := d.GetOk("credentials"); ok {
config.Credentials = v.(string)
}
if v, ok := d.GetOk("impersonate_service_account"); ok {
config.ImpersonateServiceAccount = v.(string)
}

scopes := d.Get("scopes").([]interface{})
Expand All @@ -478,6 +494,14 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData, p *schema.Pr
config.Scopes[i] = scope.(string)
}

delegates := d.Get("impersonate_service_account_delegates").([]interface{})
if len(delegates) > 0 {
config.ImpersonateServiceAccountDelegates = make([]string, len(delegates))
}
for i, delegate := range delegates {
config.ImpersonateServiceAccountDelegates[i] = delegate.(string)
}

batchCfg, err := expandProviderBatchingConfig(d.Get("batching"))
if err != nil {
return nil, diag.FromErr(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ location (`zone` and/or `region`) for your resources.

```hcl
provider "google" {
credentials = file("account.json")
project = "my-project-id"
region = "us-central1"
zone = "us-central1-c"
Expand All @@ -27,7 +26,6 @@ provider "google" {

```hcl
provider "google-beta" {
credentials = file("account.json")
project = "my-project-id"
region = "us-central1"
zone = "us-central1-c"
Expand Down Expand Up @@ -62,6 +60,29 @@ resource "google_compute_instance" "beta-instance" {
provider "google-beta" {}
```

## Authentication

### Running Terraform on your workstation.

If you are using terraform on your workstation, you will need to install the Google Cloud SDK and authenticate using [User Application Default
Credentials](https://cloud.google.com/sdk/gcloud/reference/auth/application-default).

A quota project must be set which gcloud automatically reads from the `core/project` value. You can override this project by specifying `--project` flag when running `gcloud auth application-default login`. The SDK should return this message if you have set the correct billing project. `Quota project "your-project" was added to ADC which can be used by Google client libraries for billing and quota.`

### Running Terraform on Google Cloud

If you are running terraform on Google Cloud, you can configure that instance or cluster to use a [Google Service
Account](https://cloud.google.com/compute/docs/authentication). This will allow Terraform to authenticate to Google Cloud without having to bake in a separate
credential/authentication file. Make sure that the scope of the VM/Cluster is set to cloud-platform.

### Running Terraform outside of Google Cloud

If you are running terraform outside of Google Cloud, generate a service account key and set the `GOOGLE_APPPLICATION_CREDENTIALS` environment variable to
the path of the service account key. Terraform will use that key for authentication.

### Impersonating Service Accounts

Terraform can impersonate a Google Service Account as described [here](https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials). A valid credential must be provided as mentioned in the earlier section and that identity must have the `roles/iam.serviceAccountTokenCreator` role on the service account you are impersonating.

## Configuration Reference

Expand All @@ -72,15 +93,6 @@ same configuration.

### Quick Reference

* `credentials` - (Optional) Either the path to or the contents of a
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to remove this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do. I want to bury this configuration parameter at the bottom of the page. IMHO, it shouldn't be used at all and I have an open proposal to remove it entirely from terraform in the next major release.

Copy link
Member

Choose a reason for hiding this comment

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

I believe credentials is the most commonly used authentication method for the provider. Depriorisiting it in the middle of the 3.X chain is probably something we should avoid doing, at least until we've committed to removing the field in 4.0.0 (assuming we do so).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll leave it in the Quick reference but I'll move it down.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

[service account key file] in JSON format. You can
[manage key files using the Cloud Console]. If not provided, the
application default credentials will be used. You can configure
Application Default Credentials on your personal machine by
running `gcloud auth application-default login`. If
terraform is running on a GCP machine, and this value is unset,
it will automatically use that machine's configured service account.

* `project` - (Optional) The default project to manage resources in. If another
project is specified on a resource, it will take precedence.

Expand All @@ -91,6 +103,13 @@ region is specified on a regional resource, it will take precedence.
zone should be within the default region you specified. If another zone is
specified on a zonal resource, it will take precedence.

* `impersonate_service_account` - (Optional) The service account to impersonate for all Google API Calls.
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to add an impersonate_service_account_delegates as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Delegates is a complex configuration and in most cases direct impersonation is generally sufficient. It is documented at the bottom

You must have `roles/iam.serviceAccountTokenCreator` role on that account for the impersonation to succeed.

* `credentials` - (Optional) Either the path to or the contents of a
[service account key file] in JSON format. You can
[manage key files using the Cloud Console]. If not provided, the
application default credentials will be used.
---

* `scopes` - (Optional) The list of OAuth 2.0 [scopes] requested when generating
Expand Down Expand Up @@ -166,9 +185,16 @@ are automatically available. See
for more details.

* On your computer, you can make your Google identity available by
running [`gcloud auth application-default login`][gcloud adc]. This
approach isn't recommended- some APIs are not compatible with
credentials obtained through `gcloud`.
running [`gcloud auth application-default login`][gcloud adc].

---
* `impersonate_service_account` - (Optional) The service account to impersonate for all Google API Calls.
You must have `roles/iam.serviceAccountTokenCreator` role on that account for the impersonation to succeed.
If you are using a delegation chain, you can specify that using the `impersonate_service_account_delegates` field.
Alternatively, this can be specified using the `GOOGLE_IMPERSONATE_SERVICE_ACCOUNT` environment
variable.

* `impersonate_service_account_delegates` - (Optional) The delegation chain for an impersonating a service account as described [here](https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-delegated).

---

Expand Down