From aaa68e2ffc90808b1b55768422a0371918ab9478 Mon Sep 17 00:00:00 2001 From: Ivan Gusev Date: Thu, 17 Nov 2022 15:46:12 -0500 Subject: [PATCH] Add support for GOOGLE_CREDENTIALS environment variable for JSON key file --- docs/index.md | 2 +- go.mod | 5 + go.sum | 1 + ipam/config.go | 115 ++++++++++++++++++ ipam/config/config.go | 20 --- ipam/provider.go | 94 ++++++++++++-- ipam/{resources => }/resource_ip_range.go | 53 ++------ .../resource_routing_domain.go | 29 ++--- ipam/utils.go | 48 ++++++++ 9 files changed, 279 insertions(+), 88 deletions(-) create mode 100644 ipam/config.go delete mode 100644 ipam/config/config.go rename ipam/{resources => }/resource_ip_range.go (84%) rename ipam/{resources => }/resource_routing_domain.go (93%) create mode 100644 ipam/utils.go diff --git a/docs/index.md b/docs/index.md index 6abb76a..fa1b741 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,5 +55,5 @@ resource "ipam_ip_range" "sub1" { ### Authentication -The provider uses application default credentials to authenticate against the backend. Alternatively you can directly provide an identity token via the GCP_IDENTITY_TOKEN environment variable to access the Cloud Run instance, the audience for the identity token should be the domain of the Cloud Run service. +The provider uses application default credentials to authenticate against the backend. Alternatively you can set GOOGLE_CREDENTIALS variable to point or contain JSON account key or directly provide an identity token via the GCP_IDENTITY_TOKEN environment variable to access the Cloud Run instance, the audience for the identity token should be the domain of the Cloud Run service. diff --git a/go.mod b/go.mod index ea81c2d..260595a 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/openx/terraform-provider-gcp-ipam-autopilot go 1.18 require ( + github.com/go-git/go-git/v5 v5.4.2 github.com/hashicorp/terraform-plugin-docs v0.13.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.0 + github.com/mitchellh/go-homedir v1.1.0 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 google.golang.org/api v0.34.0 ) @@ -20,6 +22,8 @@ require ( github.com/bgentry/speakeasy v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.13.0 // indirect + github.com/go-git/gcfg v1.5.0 // indirect + github.com/go-git/go-billy/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.9 // indirect @@ -71,4 +75,5 @@ require ( google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d // indirect google.golang.org/grpc v1.48.0 // indirect google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 7ae7fd4..b1c6f0a 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,7 @@ github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce h1:RPclfga2SEJmgMmz2k github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/ipam/config.go b/ipam/config.go new file mode 100644 index 0000000..7241a56 --- /dev/null +++ b/ipam/config.go @@ -0,0 +1,115 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ipam + +import ( + "context" + "fmt" + "log" + + "golang.org/x/oauth2" + googleoauth "golang.org/x/oauth2/google" + "google.golang.org/api/idtoken" + "google.golang.org/api/option" +) + +// Config is used as a general provider config throughout the provider +type Config struct { + Url string + AccessToken string + Credentials string + context context.Context +} + +// staticTokenSource is used to be able to identify static token sources without reflection. +type staticTokenSource struct { + oauth2.TokenSource +} + +const ( + userInfoScope = "https://www.googleapis.com/auth/userinfo.email" +) + +func (c *Config) getToken() (string, error) { + + creds, err := c.GetCredentials([]string{userInfoScope}) + if err != nil { + return "", fmt.Errorf("error calling getCredentials(): %v", err) + } + + ctx := context.Background() + + targetAudience := c.Url // "http://ipam-autopilot.com" + + co := []option.ClientOption{} + 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) + } + + return idToken.AccessToken, nil +} + +// Get a set of credentials with a given scope (clientScopes) based on the Config object. +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} + + log.Printf("[INFO] Authenticating using configured Google JSON 'access_token'...") + log.Printf("[INFO] -- Scopes: %s", clientScopes) + return googleoauth.Credentials{ + TokenSource: staticTokenSource{oauth2.StaticTokenSource(token)}, + }, nil + } + + if c.Credentials != "" { + contents, _, err := pathOrContents(c.Credentials) + if err != nil { + return googleoauth.Credentials{}, fmt.Errorf("error loading credentials: %s", err) + } + + 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) + } + + log.Printf("[INFO] Authenticating using configured Google JSON 'credentials'...") + log.Printf("[INFO] -- Scopes: %s", clientScopes) + return *creds, nil + } + + log.Printf("[INFO] Authenticating using DefaultClient...") + log.Printf("[INFO] -- Scopes: %s", clientScopes) + defaultTS, err := googleoauth.DefaultTokenSource(context.Background(), clientScopes...) + if err != nil { + return googleoauth.Credentials{}, fmt.Errorf("Attempted to load application default credentials since neither `credentials` nor `access_token` was set in the provider block. No credentials loaded. To use your gcloud credentials, run 'gcloud auth application-default login'. Original error: %w", err) + } + + return googleoauth.Credentials{ + TokenSource: defaultTS, + }, err +} diff --git a/ipam/config/config.go b/ipam/config/config.go deleted file mode 100644 index 4683a54..0000000 --- a/ipam/config/config.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2021 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package config - -// Config is used as a general provider config throughout the provider -type Config struct { - Url string -} diff --git a/ipam/provider.go b/ipam/provider.go index 624f049..cf0aa28 100644 --- a/ipam/provider.go +++ b/ipam/provider.go @@ -15,18 +15,20 @@ package ipam import ( + "context" "fmt" "os" - "github.com/openx/terraform-provider-gcp-ipam-autopilot/ipam/config" - "github.com/openx/terraform-provider-gcp-ipam-autopilot/ipam/resources" - + //"github.com/google/martian/v3/log" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + googleoauth "golang.org/x/oauth2/google" ) // Provider for Simple IPAM func Provider() *schema.Provider { - return &schema.Provider{ + provider := &schema.Provider{ Schema: map[string]*schema.Schema{ "url": { Type: schema.TypeString, @@ -34,29 +36,99 @@ func Provider() *schema.Provider { Default: "", Description: "URL where to connect with the IPAM Autopilot backend", }, + "credentials": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validateCredentials, + ConflictsWith: []string{"access_token"}, + }, + + "access_token": { + Type: schema.TypeString, + Optional: true, + ConflictsWith: []string{"credentials"}, + }, }, ResourcesMap: map[string]*schema.Resource{ - "ipam_ip_range": resources.ResourceIpRange(), - "ipam_routing_domain": resources.ResourceRoutingDomain(), + "ipam_ip_range": ResourceIpRange(), + "ipam_routing_domain": ResourceRoutingDomain(), }, DataSourcesMap: map[string]*schema.Resource{}, - ConfigureFunc: providerConfigure, } + provider.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { + return providerConfigure(ctx, d, provider) + } + + return provider } -func providerConfigure(d *schema.ResourceData) (interface{}, error) { +func providerConfigure(ctx context.Context, d *schema.ResourceData, p *schema.Provider) (interface{}, diag.Diagnostics) { + config := Config{} + url := d.Get("url").(string) if url == "" { url = os.Getenv("IPAM_URL") } if url == "" { - return nil, fmt.Errorf("URL needed to access IPAM Autopilot") + return nil, diag.Errorf("URL needed to access IPAM Autopilot") + } + + config.Url = url + + // Check for primary credentials in config. Note that if neither is set, ADCs + // will be used if available. + if v, ok := d.GetOk("access_token"); ok { + config.AccessToken = v.(string) } - config := config.Config{ - Url: url, + if v, ok := d.GetOk("credentials"); ok { + config.Credentials = v.(string) + } + + // only check environment variables if neither value was set in config- this + // means config beats env var in all cases. + if config.AccessToken == "" && config.Credentials == "" { + config.Credentials = multiEnvSearch([]string{ + "GOOGLE_CREDENTIALS", + "GOOGLE_CLOUD_KEYFILE_JSON", + "GCLOUD_KEYFILE_JSON", + }) + + // Retrieve token if credentials were available in one of ENV variables + if config.Credentials != "" { + + token, err := config.getToken() + if err != nil { + return "", diag.Errorf("unable to retrieve identityToken: %v", err) + } + config.AccessToken = token + return config, nil + } + + config.AccessToken = multiEnvSearch([]string{ + "GOOGLE_OAUTH_ACCESS_TOKEN", + "GCP_IDENTITY_TOKEN", + }) + } return config, nil } + +func validateCredentials(v interface{}, k string) (warnings []string, errors []error) { + if v == nil || v.(string) == "" { + return + } + creds := v.(string) + // if this is a path and we can stat it, assume it's ok + if _, err := os.Stat(creds); err == nil { + return + } + if _, err := googleoauth.CredentialsFromJSON(context.Background(), []byte(creds)); err != nil { + errors = append(errors, + fmt.Errorf("JSON credentials are not valid: %s", err)) + } + + return +} diff --git a/ipam/resources/resource_ip_range.go b/ipam/resource_ip_range.go similarity index 84% rename from ipam/resources/resource_ip_range.go rename to ipam/resource_ip_range.go index 75249fc..a8d5070 100644 --- a/ipam/resources/resource_ip_range.go +++ b/ipam/resource_ip_range.go @@ -12,21 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resources +package ipam import ( "bytes" - "context" "encoding/json" "fmt" "io/ioutil" "net/http" - "os" - "github.com/openx/terraform-provider-gcp-ipam-autopilot/ipam/config" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "golang.org/x/oauth2/google" - "google.golang.org/api/idtoken" ) func ResourceIpRange() *schema.Resource { @@ -67,13 +62,14 @@ func ResourceIpRange() *schema.Resource { } } func resourceCreate(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) range_size := d.Get("range_size").(int) parent := d.Get("parent").(string) name := d.Get("name").(string) domain := d.Get("domain").(string) cidr := d.Get("cidr").(string) url := fmt.Sprintf("%s/ranges", config.Url) + accessToken := config.AccessToken var postBody []byte var err error if parent == "" { @@ -100,7 +96,7 @@ func resourceCreate(d *schema.ResourceData, meta interface{}) error { } fmt.Printf("%s", string(postBody)) responseBody := bytes.NewBuffer(postBody) - accessToken, err := getIdentityToken() + if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } @@ -140,14 +136,15 @@ func resourceCreate(d *schema.ResourceData, meta interface{}) error { } func resourceRead(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) url := fmt.Sprintf("%s/ranges/%s", config.Url, d.Id()) req, err := http.NewRequest("GET", url, nil) if err != nil { return fmt.Errorf("failed creating request: %v", err) } - accessToken, err := getIdentityToken() + accessToken := config.AccessToken + if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } @@ -182,7 +179,7 @@ func resourceRead(d *schema.ResourceData, meta interface{}) error { } func resourceDelete(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) url := fmt.Sprintf("%s/ranges/%s", config.Url, d.Id()) @@ -191,7 +188,9 @@ func resourceDelete(d *schema.ResourceData, meta interface{}) error { if err != nil { return fmt.Errorf("failed creating release request: %v", err) } - accessToken, err := getIdentityToken() + + accessToken := config.AccessToken + if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } @@ -211,33 +210,3 @@ func resourceDelete(d *schema.ResourceData, meta interface{}) error { return fmt.Errorf("failed releasing range status_code=%d, status=%s,body=%s", resp.StatusCode, resp.Status, string(body)) } } - -func getIdentityToken() (string, error) { - if os.Getenv("GCP_IDENTITY_TOKEN") != "" { - return os.Getenv("GCP_IDENTITY_TOKEN"), nil - } - - ctx := context.Background() - audience := "http://ipam-autopilot.com" - ts, err := idtoken.NewTokenSource(ctx, audience) - if err != nil { - if err.Error() != `idtoken: credential must be service_account, found "authorized_user"` { - return "", err - } - gts, err := google.DefaultTokenSource(ctx) - if err != nil { - return "", err - } - token, err := gts.Token() - if err != nil { - return "", err - } - identityToken := token.Extra("id_token").(string) - return identityToken, nil - } - token, err := ts.Token() - if err != nil { - return "", err - } - return token.AccessToken, nil -} diff --git a/ipam/resources/resource_routing_domain.go b/ipam/resource_routing_domain.go similarity index 93% rename from ipam/resources/resource_routing_domain.go rename to ipam/resource_routing_domain.go index b8e92a7..011ffc4 100644 --- a/ipam/resources/resource_routing_domain.go +++ b/ipam/resource_routing_domain.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resources +package ipam import ( "bytes" @@ -22,7 +22,6 @@ import ( "net/http" "strings" - "github.com/openx/terraform-provider-gcp-ipam-autopilot/ipam/config" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -53,7 +52,7 @@ func ResourceRoutingDomain() *schema.Resource { } func routingDomainCreate(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) vpcs := d.Get("vpcs").([]interface{}) name := d.Get("name").(string) url := fmt.Sprintf("%s/domains", config.Url) @@ -68,20 +67,19 @@ func routingDomainCreate(d *schema.ResourceData, meta interface{}) error { } fmt.Printf("%s", string(postBody)) responseBody := bytes.NewBuffer(postBody) - accessToken, err := getIdentityToken() - if err != nil { - return fmt.Errorf("unable to retrieve access token: %v", err) - } + accessToken := config.AccessToken + req, err := http.NewRequest("POST", url, responseBody) if err != nil { return fmt.Errorf("failed creating request: %v", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) req.Header.Set("Content-Type", "application/json") + client := &http.Client{} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed creating range: %v", err) + return fmt.Errorf("failed creating domain: %v", err) } defer resp.Body.Close() if resp.StatusCode == 200 { @@ -109,18 +107,20 @@ func routingDomainCreate(d *schema.ResourceData, meta interface{}) error { } func routingDomainRead(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) url := fmt.Sprintf("%s/domains/%s", config.Url, d.Id()) req, err := http.NewRequest("GET", url, nil) if err != nil { return fmt.Errorf("failed creating request: %v", err) } - accessToken, err := getIdentityToken() + accessToken := config.AccessToken + if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + client := &http.Client{} resp, err := client.Do(req) if err != nil { @@ -157,7 +157,7 @@ func routingDomainRead(d *schema.ResourceData, meta interface{}) error { } func routingDomainDelete(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) url := fmt.Sprintf("%s/domains/%s", config.Url, d.Id()) @@ -166,7 +166,7 @@ func routingDomainDelete(d *schema.ResourceData, meta interface{}) error { if err != nil { return fmt.Errorf("failed deleting routing domain request: %v", err) } - accessToken, err := getIdentityToken() + accessToken := config.AccessToken if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } @@ -188,7 +188,7 @@ func routingDomainDelete(d *schema.ResourceData, meta interface{}) error { } func routingDomainUpdate(d *schema.ResourceData, meta interface{}) error { - config := meta.(config.Config) + config := meta.(Config) vpcs := d.Get("vpcs").([]interface{}) name := d.Get("name").(string) url := fmt.Sprintf("%s/domains/%s", config.Url, d.Id()) @@ -203,7 +203,8 @@ func routingDomainUpdate(d *schema.ResourceData, meta interface{}) error { } fmt.Printf("%s", string(postBody)) responseBody := bytes.NewBuffer(postBody) - accessToken, err := getIdentityToken() + accessToken := config.AccessToken + if err != nil { return fmt.Errorf("unable to retrieve access token: %v", err) } diff --git a/ipam/utils.go b/ipam/utils.go new file mode 100644 index 0000000..dfa673c --- /dev/null +++ b/ipam/utils.go @@ -0,0 +1,48 @@ +package ipam + +import ( + "io/ioutil" + "os" + + "github.com/mitchellh/go-homedir" +) + +func multiEnvSearch(ks []string) string { + for _, k := range ks { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} + +// If the argument is a path, pathOrContents loads it and returns the contents, +// otherwise the argument is assumed to be the desired contents and is simply +// returned. +// +// The boolean second return value can be called `wasPath` - it indicates if a +// path was detected and a file loaded. +func pathOrContents(poc string) (string, bool, error) { + if len(poc) == 0 { + return poc, false, nil + } + + path := poc + if path[0] == '~' { + var err error + path, err = homedir.Expand(path) + if err != nil { + return path, true, err + } + } + + if _, err := os.Stat(path); err == nil { + contents, err := ioutil.ReadFile(path) + if err != nil { + return string(contents), true, err + } + return string(contents), true, nil + } + + return poc, false, nil +}