diff --git a/go.mod b/go.mod index 16ba1dd6bae..7b203e15f18 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,9 @@ require ( github.com/sirupsen/logrus v1.2.0 // indirect github.com/stoewer/go-strcase v1.0.2 github.com/terraform-providers/terraform-provider-random v0.0.0-20190925200408-30dac3233094 + golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d - google.golang.org/api v0.25.0 + google.golang.org/api v0.26.0 ) go 1.14 diff --git a/go.sum b/go.sum index 6ccd7f84529..2c05e14bc48 100644 --- a/go.sum +++ b/go.sum @@ -659,6 +659,8 @@ google.golang.org/api v0.23.0 h1:YlvGEOq2NA2my8cZ/9V8BcEO9okD48FlJcdqN0xJL3s= google.golang.org/api v0.23.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.25.0 h1:LodzhlzZEUfhXzNUMIfVlf9Gr6Ua5MMtoFWh7+f47qA= google.golang.org/api v0.25.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.26.0 h1:VJZ8h6E8ip82FRpQl848c5vAadxlTXrUh8RzQzSRm08= +google.golang.org/api v0.26.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -749,4 +751,4 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4 h1:JPJh2pk3+X4lXAkZIk2RuE/7/FoK9maXw+TNPJhVS/c= -sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= \ No newline at end of file diff --git a/google/config.go b/google/config.go index 16e237a463f..fee0ebefe32 100644 --- a/google/config.go +++ b/google/config.go @@ -694,37 +694,59 @@ func (c *Config) synchronousTimeout() time.Duration { } func (c *Config) getTokenSource(clientScopes []string) (oauth2.TokenSource, error) { + creds, err := c.GetCredentials(clientScopes) + if err != nil { + return nil, fmt.Errorf("%s", err) + } + return creds.TokenSource, nil +} + +// staticTokenSource is used to be able to identify static token sources without reflection. +type staticTokenSource struct { + oauth2.TokenSource +} + +func (c *Config) GetCredentials(clientScopes []string) (googleoauth.Credentials, error) { if c.AccessToken != "" { contents, _, err := pathorcontents.Read(c.AccessToken) if err != nil { - return nil, fmt.Errorf("Error loading access token: %s", err) + return googleoauth.Credentials{}, fmt.Errorf("Error loading access token: %s", err) } - log.Printf("[INFO] Authenticating using configured Google JSON 'access_token'...") log.Printf("[INFO] -- Scopes: %s", clientScopes) token := &oauth2.Token{AccessToken: contents} - return oauth2.StaticTokenSource(token), nil + + return googleoauth.Credentials{ + TokenSource: staticTokenSource{oauth2.StaticTokenSource(token)}, + }, nil } if c.Credentials != "" { contents, _, err := pathorcontents.Read(c.Credentials) if err != nil { - return nil, fmt.Errorf("Error loading credentials: %s", err) + return googleoauth.Credentials{}, fmt.Errorf("error loading credentials: %s", err) } - creds, err := googleoauth.CredentialsFromJSON(context.Background(), []byte(contents), clientScopes...) + creds, err := googleoauth.CredentialsFromJSON(c.context, []byte(contents), clientScopes...) if err != nil { - return nil, fmt.Errorf("Unable to parse credentials: %s", err) + return googleoauth.Credentials{}, fmt.Errorf("unable to parse credentials from '%s': %s", contents, err) } - log.Printf("[INFO] Authenticating using configured Google JSON 'credentials'...") log.Printf("[INFO] -- Scopes: %s", clientScopes) - return creds.TokenSource, nil + return *creds, nil } log.Printf("[INFO] Authenticating using DefaultClient...") log.Printf("[INFO] -- Scopes: %s", clientScopes) - return googleoauth.DefaultTokenSource(context.Background(), clientScopes...) + + defaultTS, err := googleoauth.DefaultTokenSource(context.Background(), clientScopes...) + if err != nil { + return googleoauth.Credentials{}, fmt.Errorf("Error loading Default TokenSource: %s", err) + } + return googleoauth.Credentials{ + TokenSource: defaultTS, + }, err + } // Remove the `/{{version}}/` from a base path if present. diff --git a/google/data_source_google_service_account_id_token.go b/google/data_source_google_service_account_id_token.go new file mode 100644 index 00000000000..7c7b32a9488 --- /dev/null +++ b/google/data_source_google_service_account_id_token.go @@ -0,0 +1,127 @@ +package google + +import ( + "time" + + "fmt" + "strings" + + iamcredentials "google.golang.org/api/iamcredentials/v1" + "google.golang.org/api/idtoken" + "google.golang.org/api/option" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "golang.org/x/net/context" +) + +const ( + userInfoScope = "https://www.googleapis.com/auth/userinfo.email" +) + +func dataSourceGoogleServiceAccountIdToken() *schema.Resource { + + return &schema.Resource{ + Read: dataSourceGoogleServiceAccountIdTokenRead, + Schema: map[string]*schema.Schema{ + "target_audience": { + Type: schema.TypeString, + Required: true, + }, + "target_service_account": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validateRegexp("(" + strings.Join(PossibleServiceAccountNames, "|") + ")"), + }, + "delegates": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validateRegexp(ServiceAccountLinkRegex), + }, + }, + "include_email": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + // Not used currently + // https://github.com/googleapis/google-api-go-client/issues/542 + // "format": { + // Type: schema.TypeString, + // Optional: true, + // ValidateFunc: validation.StringInSlice([]string{ + // "FULL", "STANDARD"}, true), + // Default: "STANDARD", + // }, + "id_token": { + Type: schema.TypeString, + Sensitive: true, + Computed: true, + }, + }, + } +} + +func dataSourceGoogleServiceAccountIdTokenRead(d *schema.ResourceData, meta interface{}) error { + + config := meta.(*Config) + targetAudience := d.Get("target_audience").(string) + creds, err := config.GetCredentials([]string{userInfoScope}) + if err != nil { + return fmt.Errorf("error calling getCredentials(): %v", err) + } + + ts := creds.TokenSource + + // If the source token is just an access_token, all we can do is use the iamcredentials api to get an id_token + if _, ok := ts.(staticTokenSource); ok { + // Use + // https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken + service := config.clientIamCredentials + name := fmt.Sprintf("projects/-/serviceAccounts/%s", d.Get("target_service_account").(string)) + tokenRequest := &iamcredentials.GenerateIdTokenRequest{ + Audience: targetAudience, + IncludeEmail: d.Get("include_email").(bool), + Delegates: convertStringSet(d.Get("delegates").(*schema.Set)), + } + at, err := service.Projects.ServiceAccounts.GenerateIdToken(name, tokenRequest).Do() + if err != nil { + return fmt.Errorf("error calling iamcredentials.GenerateIdToken: %v", err) + } + + d.SetId(time.Now().UTC().String()) + d.Set("id_token", at.Token) + + return nil + } + + tok, err := ts.Token() + if err != nil { + return fmt.Errorf("unable to get Token() from tokenSource: %v", err) + } + + // only user-credential TokenSources have refreshTokens + if tok.RefreshToken != "" { + return fmt.Errorf("unsupported Credential Type supplied. Use serviceAccount credentials") + } + ctx := context.Background() + co := []option.ClientOption{} + if creds.JSON != nil { + co = append(co, idtoken.WithCredentialsJSON(creds.JSON)) + } + + idTokenSource, err := idtoken.NewTokenSource(ctx, targetAudience, co...) + if err != nil { + return fmt.Errorf("unable to retrieve TokenSource: %v", err) + } + idToken, err := idTokenSource.Token() + if err != nil { + return fmt.Errorf("unable to retrieve Token: %v", err) + } + + d.SetId(time.Now().UTC().String()) + d.Set("id_token", idToken.AccessToken) + + return nil +} diff --git a/google/data_source_google_service_account_id_token_test.go b/google/data_source_google_service_account_id_token_test.go new file mode 100644 index 00000000000..9ef4b259935 --- /dev/null +++ b/google/data_source_google_service_account_id_token_test.go @@ -0,0 +1,111 @@ +package google + +import ( + "context" + "testing" + + "fmt" + + "google.golang.org/api/idtoken" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" +) + +const targetAudience = "https://foo.bar/" + +func testAccCheckServiceAccountIdTokenValue(name, audience string) resource.TestCheckFunc { + return func(s *terraform.State) error { + ms := s.RootModule() + + rs, ok := ms.Resources[name] + if !ok { + return fmt.Errorf("can't find %s in state", name) + } + + v, ok := rs.Primary.Attributes["id_token"] + if !ok { + return fmt.Errorf("id_token not found") + } + + _, err := idtoken.Validate(context.Background(), v, audience) + if err != nil { + return fmt.Errorf("token validation failed: %v", err) + } + + return nil + } +} + +func TestAccDataSourceGoogleServiceAccountIdToken_basic(t *testing.T) { + t.Parallel() + + resourceName := "data.google_service_account_id_token.default" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleServiceAccountIdToken_basic(targetAudience), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "target_audience", targetAudience), + testAccCheckServiceAccountIdTokenValue(resourceName, targetAudience), + ), + }, + }, + }) +} + +func testAccCheckGoogleServiceAccountIdToken_basic(targetAudience string) string { + + return fmt.Sprintf(` +data "google_service_account_id_token" "default" { + target_audience = "%s" +} +`, targetAudience) +} + +func TestAccDataSourceGoogleServiceAccountIdToken_impersonation(t *testing.T) { + t.Parallel() + + resourceName := "data.google_service_account_id_token.default" + serviceAccount := getTestServiceAccountFromEnv(t) + targetServiceAccountEmail := BootstrapServiceAccount(t, getTestProjectFromEnv(), serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleServiceAccountIdToken_impersonation_datasource(targetAudience, targetServiceAccountEmail), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "target_audience", targetAudience), + testAccCheckServiceAccountIdTokenValue(resourceName, targetAudience), + ), + }, + }, + }) +} + +func testAccCheckGoogleServiceAccountIdToken_impersonation_datasource(targetAudience string, targetServiceAccount string) string { + + return fmt.Sprintf(` +data "google_service_account_access_token" "default" { + target_service_account = "%s" + scopes = ["userinfo-email", "https://www.googleapis.com/auth/cloud-platform"] + lifetime = "30s" +} + +provider google { + alias = "impersonated" + access_token = data.google_service_account_access_token.default.access_token +} + +data "google_service_account_id_token" "default" { + provider = google.impersonated + target_service_account = "%s" + target_audience = "%s" +} +`, targetServiceAccount, targetServiceAccount, targetAudience) +} diff --git a/google/provider.go b/google/provider.go index c6cdd6f2d5f..58ce1f0aa4d 100644 --- a/google/provider.go +++ b/google/provider.go @@ -546,6 +546,7 @@ func Provider() terraform.ResourceProvider { "google_secret_manager_secret_version": dataSourceSecretManagerSecretVersion(), "google_service_account": dataSourceGoogleServiceAccount(), "google_service_account_access_token": dataSourceGoogleServiceAccountAccessToken(), + "google_service_account_id_token": dataSourceGoogleServiceAccountIdToken(), "google_service_account_key": dataSourceGoogleServiceAccountKey(), "google_sql_ca_certs": dataSourceGoogleSQLCaCerts(), "google_storage_bucket_object": dataSourceGoogleStorageBucketObject(), diff --git a/website/docs/d/datasource_google_service_account_id_token.html.markdown b/website/docs/d/datasource_google_service_account_id_token.html.markdown new file mode 100644 index 00000000000..1d05c8bb88c --- /dev/null +++ b/website/docs/d/datasource_google_service_account_id_token.html.markdown @@ -0,0 +1,99 @@ +--- +subcategory: "Cloud Platform" +layout: "google" +page_title: "Google: google_service_account_id_token" +sidebar_current: "docs-google-service-account-id-token" +description: |- + Produces OpenID Connect token for service accounts +--- + +# google\_service\_account\id\_token + +This data source provides a Google OpenID Connect (`oidc`) `id_token`. Tokens issued from this data source are typically used to call external services that accept OIDC tokens for authentication (e.g. [Google Cloud Run](https://cloud.google.com/run/docs/authenticating/service-to-service)). + +For more information see +[OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html#IDToken). + +## Example Usage - ServiceAccount JSON credential file. + `google_service_account_id_token` will use the configured [provider credentials](https://www.terraform.io/docs/providers/google/guides/provider_reference.html#credentials-1) + + ```hcl + data "google_service_account_id_token" oidc { + target_audience = "https://foo.bar/" + } + + output "oidc_token" { + value = data.google_service_account_id_token.oidc.id_token + } + ``` + +## Example Usage - Service Account Impersonation. + `google_service_account_access_token` will use background impersonated credentials provided by [google_service_account_access_token](https://www.terraform.io/docs/providers/google/d/datasource_google_service_account_access_token.html). + + Note: to use the following, you must grant `target_service_account` the + `roles/iam.serviceAccountTokenCreator` role on itself. + + ```hcl + data "google_service_account_access_token" "impersonated" { + provider = google + target_service_account = "impersonated-account@project.iam.gserviceaccount.com" + delegates = [] + scopes = ["userinfo-email", "cloud-platform"] + lifetime = "300s" + } + + provider google { + alias = "impersonated" + access_token = data.google_service_account_access_token.impersonated.access_token + } + + data "google_service_account_id_token" oidc { + provider = google.impersonated + target_service_account = "impersonated-account@project.iam.gserviceaccount.com" + delegates = [] + include_email = true + target_audience = "https://foo.bar/" + } + + output "oidc_token" { + value = data.google_service_account_id_token.oidc.id_token + } + ``` + +## Example Usage - Invoking Cloud Run Endpoint + + The following configuration will invoke [Cloud Run](https://cloud.google.com/run/docs/authenticating/service-to-service) endpoint where the service account for Terraform has been granted `roles/run.invoker` role previously. + +```hcl + +data "google_service_account_id_token" "oidc" { + target_audience = "https://your.cloud.run.app/" +} + +data "http" "cloudrun" { + url = "https://your.cloud.run.app/" + request_headers = { + Authorization = "Bearer ${data.google_service_account_id_token.oidc.id_token}" + } +} + + +output "cloud_run_response" { + value = data.http.cloudrun.body +} +``` + +## Argument Reference + +The following arguments are supported: + +* `target_audience` (Required) - The audience claim for the `id_token`. +* `target_service_account` (Optional) - The email of the service account being impersonated. Used only when using impersonation mode. +* `delegates` (Optional) - Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. Used only when using impersonation mode. +* `include_email` (Optional) Include the verified email in the claim. Used only when using impersonation mode. + +## Attributes Reference + +The following attribute is exported: + +* `id_token` - The `id_token` representing the new generated identity.