diff --git a/.github/workflows/fabric_acctest.yml b/.github/workflows/fabric_acctest.yml index f3fe2cd53..41fdff234 100644 --- a/.github/workflows/fabric_acctest.yml +++ b/.github/workflows/fabric_acctest.yml @@ -26,8 +26,10 @@ on: workflow_dispatch: permissions: + #actions: write required to update OIDC subject claim template pull-requests: read contents: read + id-token: write jobs: @@ -44,6 +46,8 @@ jobs: steps: - run: true + # The GitHub OIDC subject claim template has been customized at the repository level to include only the workflow name. Long-term, we should consider using the default template for tests. + build: name: Build needs: authorize @@ -85,7 +89,6 @@ jobs: terraform: - '1.5' steps: - - name: Check out code into the Go module directory uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: @@ -161,7 +164,6 @@ jobs: terraform: - '1.5' steps: - - name: Check out code into the Go module directory uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: @@ -223,6 +225,81 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage_pfcr.txt + test-STS-creds: + name: Matrix Test + needs: build + runs-on: ubuntu-latest + env: + EQUINIX_API_ENDPOINT: "https://uatapi.equinix.com" + EQUINIX_STS_ENDPOINT: "https://sts.uat.equinix.com" + timeout-minutes: 240 + strategy: + fail-fast: false + matrix: + version: + - stable + terraform: + - '1.5' + sts_config: + - name: "default" + env_var_name: "EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN" + set_custom_env_var: false + token_exchange_subject_token_env_var: null + - name: "custom" + env_var_name: "CUSTOM_STS_TOKEN" + set_custom_env_var: true + token_exchange_subject_token_env_var: "CUSTOM_STS_TOKEN" + steps: + - id: get_id_token + name: Get GitHub OIDC Token for PFCR + uses: actions/github-script@v6 + with: + script: | + try { + const idToken = await core.getIDToken('gha-fcr-client'); + console.log('Token generated with audience: gha-fcr-client'); + core.setOutput('id_token', idToken); + } catch (error) { + console.error('Error getting OIDC token:', error.message); + core.setFailed(`Error getting OIDC token: ${error.message}`); + } + result-encoding: string + + - name: Check out code into the Go module directory + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: './go.mod' + id: go + + - name: Get dependencies + run: | + go mod download + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: ${{ matrix.terraform }} + terraform_wrapper: false + + - name: TF Fabric PFCR acceptance tests STS creds + timeout-minutes: 180 + env: + TF_ACC: "1" + TF_ACC_FABRIC_CONNECTIONS_TEST_DATA: ${{ secrets.TF_ACC_FABRIC_CONNECTIONS_TEST_DATA }} + TF_ACC_FABRIC_DEDICATED_PORTS: ${{ secrets.TF_ACC_FABRIC_DEDICATED_PORTS }} + TF_ACC_FABRIC_MARKET_PLACE_SUBSCRIPTION_ID: ${{ secrets.TF_ACC_FABRIC_MARKET_PLACE_SUBSCRIPTION_ID }} + TF_ACC_FABRIC_STREAM_TEST_DATA: ${{ secrets.TF_ACC_FABRIC_STREAM_TEST_DATA }} + EQUINIX_TOKEN_EXCHANGE_SCOPE: ${{ secrets.EQUINIX_STS_AUTH_SCOPE_PFCR }} + METAL_AUTH_TOKEN: ${{ secrets.METAL_AUTH_TOKEN }} + ${{ matrix.sts_config.env_var_name }}: ${{ steps.get_id_token.outputs.id_token }} + ${{ matrix.sts_config.set_custom_env_var && 'EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN_ENV_VAR' || 'SKIP' }}: ${{ matrix.sts_config.token_exchange_subject_token_env_var || '' }} + run: | + go test ./... --run "(TestAccFabricCreatePort2SPConnection_PFCR|TestAccCloudRouterCreateOnlyRequiredParameters_PFCR)" -v -coverprofile coverage_pfcr.txt -covermode=atomic -count 1 + upload-test-report: name: Upload Testing Report if: always() diff --git a/docs/index.md b/docs/index.md index fad52e4f9..7f545f999 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,8 @@ The Equinix provider is used to interact with the resources provided by Equinix For information about obtaining API key and secret required for Equinix Fabric and Network Edge refer to [Generating Client ID and Client Secret key](https://developer.equinix.com/dev-docs/fabric/getting-started/getting-access-token#generating-client-id-and-client-secret) from [Equinix Developer Platform portal](https://developer.equinix.com). +Equinix Fabric also supports authentication using a [Workload Identity Token](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens), which can be used in place of the `client_id` and `client_secret` arguments. Requires an authorization scope and OIDC token from an IdP trusted by Equinix STS. Please note that this is an alpha feature not available for all users. Using workload identity tokens will override client ID/secret, you must use [provider aliases](https://developer.hashicorp.com/terraform/language/providers/configuration#alias-multiple-provider-configurations) to manage both workload identity tokens and client ID/secret in a single Terraform configuration. + Interacting with Equinix Metal requires an API auth token that can be generated at [Project-level](https://metal.equinix.com/developers/docs/accounts/projects/#api-keys) or [User-level](https://metal.equinix.com/developers/docs/accounts/users/#api-keys) tokens can be used. If you are only using Equinix Metal resources, you may omit the Client ID and Client Secret provider configuration parameters needed to access other Equinix resource types (Network Edge, Fabric, etc). @@ -44,6 +46,20 @@ provider "equinix" { } ``` +Workload Identity Tokens can be used in service authorization scenarios, like HCP Terraform. Other credential variables are optional for `equinix_fabric_*` resources and datasources when using this method. + +```terraform +# Configuration for using Workload Identity Federation +provider "equinix" { + # Desired scope of the requested security token. Must be an Access Policy ERN or a string of the form `roleassignments:` + token_exchange_scope = "roleassignments:" + + # The name of the environment variable containing the token exchange subject token + # For example, HCP Terraform automatically sets TFC_WORKLOAD_IDENTITY_TOKEN + token_exchange_subject_token_env_var = "TFC_WORKLOAD_IDENTITY_TOKEN" +} +``` + Example provider configuration using `environment variables`: ```sh @@ -85,4 +101,8 @@ These parameters can be provided in [Terraform variable files](https://www.terra - `max_retry_wait_seconds` (Number) Maximum number of seconds to wait before retrying a request. - `request_timeout` (Number) The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Canceled requests may still result in provisioned resources. (Defaults to `30`) - `response_max_page_size` (Number) The maximum number of records in a single response for REST queries that produce paginated responses. (Default is client specific) +- `sts_endpoint` (String) The STS API base URL to point to the desired environment. This argument can also be specified with the `EQUINIX_STS_ENDPOINT` shell environment variable. (Defaults to `https://sts.eqix.equinix.com`). Please note that STS is an alpha feature and not available for all users. - `token` (String) API tokens are generated from API Consumer clients using the [OAuth2 API](https://developer.equinix.com/dev-docs/fabric/getting-started/getting-access-token#request-access-and-refresh-tokens). This argument can also be specified with the `EQUINIX_API_TOKEN` shell environment variable. +- `token_exchange_scope` (String) The scope of the authentication token. Must be an access policy ERN or a string of the form `roleassignments:`. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SCOPE` shell environment variable. Please note that token exchange is an alpha feature and not available for all users. +- `token_exchange_subject_token` (String) The subject token to use for token exchange authentication. Must be an OIDC ID token issued by an OIDC provider trusted by Equinix STS. If not set, the provider will use the environment variable specified in `token_exchange_subject_token_env_var`. Please note that token exchange is an alpha feature and not available for all users. +- `token_exchange_subject_token_env_var` (String) The name of the environment variable containing the subject token for token exchange. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN_ENV_VAR` shell environment variable. (Defaults to `EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN`). Please note that token exchange is an alpha feature and not available for all users. diff --git a/equinix/provider.go b/equinix/provider.go index 47fa527b0..8e0eb2bd8 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -83,6 +83,31 @@ func Provider() *schema.Provider { Default: 30, Description: "Maximum number of seconds to wait before retrying a request.", }, + "token_exchange_scope": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc(config.TokenExchangeScopeEnvVar, ""), + Description: "The scope of the authentication token. Must be an access policy ERN or a string of the form `roleassignments:`. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SCOPE` shell environment variable. Please note that token exchange is an alpha feature and not available for all users.", + }, + "sts_endpoint": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc(config.StsEndpointEnvVar, config.DefaultStsBaseURL), + ValidateFunc: validation.IsURLWithHTTPorHTTPS, + Description: fmt.Sprintf("The STS API base URL to point to the desired environment. This argument can also be specified with the `EQUINIX_STS_ENDPOINT` shell environment variable. (Defaults to `%s`). Please note that STS is an alpha feature and not available for all users.", config.DefaultStsBaseURL), + }, + "token_exchange_subject_token": { + Type: schema.TypeString, + Optional: true, + Description: "The subject token to use for token exchange authentication. Must be an OIDC ID token issued by an OIDC provider trusted by Equinix STS. If not set, the provider will use the environment variable specified in `token_exchange_subject_token_env_var`. Please note that token exchange is an alpha feature and not available for all users.", + }, + + "token_exchange_subject_token_env_var": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc(config.TokenExchangeSubjectTokenEnvVarEnvVar, config.DefaultTokenExchangeSubjectTokenEnvVar), + Description: fmt.Sprintf("The name of the environment variable containing the subject token for token exchange. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN_ENV_VAR` shell environment variable. (Defaults to `%s`). Please note that token exchange is an alpha feature and not available for all users.", config.DefaultTokenExchangeSubjectTokenEnvVar), + }, }, DataSourcesMap: datasources, ResourcesMap: resources, @@ -109,15 +134,19 @@ func configureProvider(ctx context.Context, d *schema.ResourceData, p *schema.Pr rt := d.Get("request_timeout").(int) config := config.Config{ - AuthToken: d.Get("auth_token").(string), - BaseURL: d.Get("endpoint").(string), - ClientID: d.Get("client_id").(string), - ClientSecret: d.Get("client_secret").(string), - Token: d.Get("token").(string), - RequestTimeout: time.Duration(rt) * time.Second, - PageSize: d.Get("response_max_page_size").(int), - MaxRetries: d.Get("max_retries").(int), - MaxRetryWait: time.Duration(mrws) * time.Second, + AuthToken: d.Get("auth_token").(string), + BaseURL: d.Get("endpoint").(string), + ClientID: d.Get("client_id").(string), + ClientSecret: d.Get("client_secret").(string), + Token: d.Get("token").(string), + RequestTimeout: time.Duration(rt) * time.Second, + PageSize: d.Get("response_max_page_size").(int), + MaxRetries: d.Get("max_retries").(int), + MaxRetryWait: time.Duration(mrws) * time.Second, + TokenExchangeScope: d.Get("token_exchange_scope").(string), + StsBaseURL: d.Get("sts_endpoint").(string), + TokenExchangeSubjectToken: d.Get("token_exchange_subject_token").(string), + TokenExchangeSubjectTokenEnvVar: d.Get("token_exchange_subject_token_env_var").(string), } meta := providerMeta{} diff --git a/examples/example_4.tf b/examples/example_4.tf new file mode 100644 index 000000000..7cec429f2 --- /dev/null +++ b/examples/example_4.tf @@ -0,0 +1,9 @@ +# Configuration for using Workload Identity Federation +provider "equinix" { + # Desired scope of the requested security token. Must be an Access Policy ERN or a string of the form `roleassignments:` + token_exchange_scope = "roleassignments:" + + # The name of the environment variable containing the token exchange subject token + # For example, HCP Terraform automatically sets TFC_WORKLOAD_IDENTITY_TOKEN + token_exchange_subject_token_env_var = "TFC_WORKLOAD_IDENTITY_TOKEN" +} \ No newline at end of file diff --git a/internal/acceptance/acceptance.go b/internal/acceptance/acceptance.go index 0e994f7aa..222c735f5 100644 --- a/internal/acceptance/acceptance.go +++ b/internal/acceptance/acceptance.go @@ -1,3 +1,7 @@ +// Package acceptance provides Utilities and test framework setup for running +// acceptance tests for the Equinix Terraform provider. It handles provider +// configuration, authentication verification, and prerequisite checks for +// testing against Equinix Fabric, Network Edge, and Metal services. package acceptance import ( @@ -20,9 +24,13 @@ const ( ) var ( - TestAccProvider *schema.Provider - TestAccProviders map[string]*schema.Provider - TestExternalProviders map[string]resource.ExternalProvider + // TestAccProvider is the Equinix provider instance used for acceptance testing + TestAccProvider *schema.Provider + // TestAccProviders is a map of provider names to their schema.Provider instances used in acceptance tests. + TestAccProviders map[string]*schema.Provider + // TestExternalProviders defines external providers (by name and source) required for acceptance tests. + TestExternalProviders map[string]resource.ExternalProvider + // TestAccFrameworkProvider is the FrameworkProvider instance used for advanced acceptance test scenarios. TestAccFrameworkProvider *provider.FrameworkProvider // testAccProviderConfigure ensures Provider is only configured once // @@ -46,6 +54,9 @@ func init() { TestAccFrameworkProvider = provider.CreateFrameworkProvider(version.ProviderVersion).(*provider.FrameworkProvider) } +// TestAccPreCheck verifies that the required environment variables are set +// for running acceptance tests. It checks for authentication credentials for +// Equinix Fabric, Network Edge, and Metal services. func TestAccPreCheck(t *testing.T) { var err error @@ -54,6 +65,19 @@ func TestAccPreCheck(t *testing.T) { if err == nil { _, err = env.Get(config.ClientSecretEnvVar) } + + // If neither token nor client ID/secret are configured, check for STS source token + if err != nil { + _, tokenExchangeScopeErr := env.Get(config.TokenExchangeScopeEnvVar) + + // Check if either the custom env var name is set, or the default source token is set + _, subjectTokenEnvVarErr := env.Get(config.TokenExchangeSubjectTokenEnvVarEnvVar) + _, defaultSubjectTokenErr := env.Get(config.DefaultTokenExchangeSubjectTokenEnvVar) + + if tokenExchangeScopeErr == nil && (subjectTokenEnvVarErr == nil || defaultSubjectTokenErr == nil) { + err = nil + } + } } if err == nil { @@ -61,17 +85,25 @@ func TestAccPreCheck(t *testing.T) { } if err != nil { - t.Fatalf("To run acceptance tests, one of '%s' or pair '%s' - '%s' must be set for Equinix Fabric and Network Edge, and '%s' for Equinix Metal", - config.ClientTokenEnvVar, config.ClientIDEnvVar, config.ClientSecretEnvVar, config.MetalAuthTokenEnvVar) + t.Fatalf("To run acceptance tests, one of '%s', pair '%s' - '%s', or pair '%s' - ('%s' or custom env var from '%s') must be set for Equinix Fabric and Network Edge, and '%s' for Equinix Metal", + config.ClientTokenEnvVar, config.ClientIDEnvVar, config.ClientSecretEnvVar, + config.TokenExchangeScopeEnvVar, config.DefaultTokenExchangeSubjectTokenEnvVar, + config.TokenExchangeSubjectTokenEnvVarEnvVar, config.MetalAuthTokenEnvVar) } + } +// TestAccPreCheckMetal specifically verifies that the Equinix Metal authentication token +// environment variable is set for running Metal-specific acceptance tests. func TestAccPreCheckMetal(t *testing.T) { if os.Getenv(config.MetalAuthTokenEnvVar) == "" { t.Fatalf(missingMetalToken, config.MetalAuthTokenEnvVar) } } +// TestAccPreCheckProviderConfigured ensures the provider is properly configured +// before running tests. It uses sync.Once to guarantee the provider is +// configured exactly once across all test executions. func TestAccPreCheckProviderConfigured(t *testing.T) { // Since we are outside the scope of the Terraform configuration we must // call Configure() to properly initialize the provider configuration. diff --git a/internal/config/config.go b/internal/config/config.go index 628aa0a80..69085f339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ import ( "github.com/equinix/equinix-sdk-go/services/fabricv4" "github.com/equinix/equinix-sdk-go/services/metalv1" "github.com/equinix/ne-go" + "github.com/equinix/terraform-provider-equinix/internal/sts" "github.com/equinix/terraform-provider-equinix/version" "github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/terraform-plugin-framework/tfsdk" @@ -29,12 +30,16 @@ import ( // These constants track environment variable names // that are relevant to the provider const ( - EndpointEnvVar = "EQUINIX_API_ENDPOINT" - ClientIDEnvVar = "EQUINIX_API_CLIENTID" - ClientSecretEnvVar = "EQUINIX_API_CLIENTSECRET" - ClientTokenEnvVar = "EQUINIX_API_TOKEN" - ClientTimeoutEnvVar = "EQUINIX_API_TIMEOUT" - MetalAuthTokenEnvVar = "METAL_AUTH_TOKEN" + EndpointEnvVar = "EQUINIX_API_ENDPOINT" + ClientIDEnvVar = "EQUINIX_API_CLIENTID" + ClientSecretEnvVar = "EQUINIX_API_CLIENTSECRET" + ClientTokenEnvVar = "EQUINIX_API_TOKEN" + ClientTimeoutEnvVar = "EQUINIX_API_TIMEOUT" + MetalAuthTokenEnvVar = "METAL_AUTH_TOKEN" + TokenExchangeScopeEnvVar = "EQUINIX_TOKEN_EXCHANGE_SCOPE" + TokenExchangeSubjectTokenEnvVarEnvVar = "EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN_ENV_VAR" + StsEndpointEnvVar = "EQUINIX_STS_ENDPOINT" + DefaultTokenExchangeSubjectTokenEnvVar = "EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN" ) // ProviderMeta allows passing additional metadata @@ -53,6 +58,8 @@ const ( var ( // DefaultBaseURL is the default URL to use for API requests DefaultBaseURL = "https://api.equinix.com" + // DefaultStsBaseURL is the default Security Token Service (STS) endpoint + DefaultStsBaseURL = "https://sts.eqix.equinix.com" // DefaultTimeout is the default request timeout to use for API requests DefaultTimeout = 30 ) @@ -60,15 +67,19 @@ var ( // Config is the configuration structure used to instantiate the Equinix // provider. type Config struct { - BaseURL string - AuthToken string - ClientID string - ClientSecret string - MaxRetries int - MaxRetryWait time.Duration - RequestTimeout time.Duration - PageSize int - Token string + BaseURL string + AuthToken string + ClientID string + ClientSecret string + MaxRetries int + MaxRetryWait time.Duration + RequestTimeout time.Duration + PageSize int + Token string + TokenExchangeScope string + StsBaseURL string + TokenExchangeSubjectToken string + TokenExchangeSubjectTokenEnvVar string authClient *http.Client @@ -88,6 +99,19 @@ func (c *Config) Load(ctx context.Context) error { return fmt.Errorf("'baseURL' cannot be empty") } + // Validate BaseURL + _, err := url.Parse(c.BaseURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + + // If StsBaseURL is set, validate it too + if c.StsBaseURL != "" { + _, err := url.Parse(c.StsBaseURL) + if err != nil { + return fmt.Errorf("invalid STS base URL: %w", err) + } + } c.authClient = c.newAuthClient() neClient := ne.NewClient(ctx, c.BaseURL, c.authClient) @@ -107,29 +131,58 @@ func (c *Config) Load(ctx context.Context) error { func (c *Config) newAuthClient() *http.Client { var authTransport http.RoundTripper - if c.Token != "" { - tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}) - oauthTransport := &oauth2.Transport{ - Source: tokenSource, + if c.TokenExchangeScope != "" { + sourceToken := c.resolveSourceToken() + if sourceToken != "" { + authConfig := sts.Config{ + StsAuthScope: c.TokenExchangeScope, + StsSourceToken: sourceToken, + StsBaseURL: c.StsBaseURL, + } + authTransport = authConfig.New() } - authTransport = oauthTransport - } else { - authConfig := equinixoauth2.Config{ - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, - BaseURL: c.BaseURL, + } + + // If no STS auth, fall back to existing logic + if authTransport == nil { + if c.Token != "" { + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}) + oauthTransport := &oauth2.Transport{ + Source: tokenSource, + } + authTransport = oauthTransport + } else { + authConfig := equinixoauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + BaseURL: c.BaseURL, + } + authTransport = authConfig.New() } - authTransport = authConfig.New() } authClient := http.Client{ Timeout: c.requestTimeout(), - //nolint:staticcheck // We should move to subsystem loggers, but that is a much bigger change + //nolint:staticcheck Transport: logging.NewTransport("Equinix", authTransport), } return &authClient } +func (c *Config) resolveSourceToken() string { + // First priority: explicitly configured token + if c.TokenExchangeSubjectToken != "" { + return c.TokenExchangeSubjectToken + } + + // Second priority: token from environment variable + if c.TokenExchangeSubjectTokenEnvVar != "" { + return os.Getenv(c.TokenExchangeSubjectTokenEnvVar) + } + + return "" +} + // NewFabricClientForSDK returns a terraform sdkv2 plugin compatible // equinix-sdk-go/fabricv4 client to be used to access Fabric's V4 APIs func (c *Config) NewFabricClientForSDK(_ context.Context, d *schema.ResourceData) *fabricv4.APIClient { @@ -165,8 +218,17 @@ func (c *Config) NewFabricClientForFramework(ctx context.Context, meta tfsdk.Con // newFabricClient returns the base fabricv4 client that is then used for either the sdkv2 or framework // implementations of the Terraform Provider with exported Methods func (c *Config) newFabricClient() *fabricv4.APIClient { + authClient := c.newAuthClient() + + // Configure HTTP client with retries and logging + httpClient := c.configureHTTPClient(authClient) + + return c.createFabricClient(httpClient) +} + +func (c *Config) configureHTTPClient(client *http.Client) *http.Client { //nolint:staticcheck // We should move to subsystem loggers, but that is a much bigger change - transport := logging.NewTransport("Equinix Fabric (fabricv4)", c.authClient.Transport) + transport := logging.NewTransport("Equinix Fabric (fabricv4)", client.Transport) retryClient := retryablehttp.NewClient() retryClient.HTTPClient.Transport = transport @@ -174,8 +236,11 @@ func (c *Config) newFabricClient() *fabricv4.APIClient { retryClient.RetryMax = c.MaxRetries retryClient.RetryWaitMin = time.Second retryClient.RetryWaitMax = c.MaxRetryWait - standardClient := retryClient.StandardClient() + return retryClient.StandardClient() +} + +func (c *Config) createFabricClient(httpClient *http.Client) *fabricv4.APIClient { baseURL, _ := url.Parse(c.BaseURL) configuration := fabricv4.NewConfiguration() @@ -184,12 +249,11 @@ func (c *Config) newFabricClient() *fabricv4.APIClient { URL: baseURL.String(), }, } - configuration.HTTPClient = standardClient + configuration.HTTPClient = httpClient configuration.AddDefaultHeader("X-SOURCE", "API") configuration.AddDefaultHeader("X-CORRELATION-ID", correlationId(25)) - client := fabricv4.NewAPIClient(configuration) - return client + return fabricv4.NewAPIClient(configuration) } // NewMetalClient returns a new packngo client for accessing Equinix Metal's API. diff --git a/internal/planmodifiers/immutable_int64_test.go b/internal/planmodifiers/immutable_int64_test.go index 2e56b5a58..a5719af56 100644 --- a/internal/planmodifiers/immutable_int64_test.go +++ b/internal/planmodifiers/immutable_int64_test.go @@ -11,8 +11,8 @@ import ( func TestImmutableStringSet(t *testing.T) { testCases := []struct { - Old, New, Expected int64 - ExpectError bool + Old, New, Expected int64 + ExpectError bool }{ { Old: 0, @@ -33,7 +33,7 @@ func TestImmutableStringSet(t *testing.T) { for i, testCase := range testCases { stateValue := types.Int64Value(testCase.Old) planValue := types.Int64Value(testCase.New) - expectedValue := types.Int64Null() + expectedValue := types.Int64Null() if testCase.Expected != 0 { expectedValue = types.Int64Value(testCase.Expected) } @@ -41,7 +41,7 @@ func TestImmutableStringSet(t *testing.T) { req := planmodifier.Int64Request{ StateValue: stateValue, PlanValue: planValue, - Path: path.Root("test"), + Path: path.Root("test"), } var resp planmodifier.Int64Response @@ -58,4 +58,4 @@ func TestImmutableStringSet(t *testing.T) { t.Fatalf("%d: output plan value does not equal expected. Want %d plan value, got %d", i, expectedValue, resp.PlanValue.ValueInt64()) } } -} \ No newline at end of file +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 45881fc6b..ae5ed004c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1,3 +1,5 @@ +// Package provider implements the Terraform provider for Equinix, including provider configuration, +// resource and data source registration, and integration with the Terraform Plugin Framework. package provider import ( @@ -16,28 +18,32 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) +// FrameworkProvider implements the Terraform provider, holding version and configuration metadata. type FrameworkProvider struct { ProviderVersion string Meta *config.Config } +// CreateFrameworkProvider initializes a new FrameworkProvider with the specified version. func CreateFrameworkProvider(version string) provider.ProviderWithMetaSchema { return &FrameworkProvider{ ProviderVersion: version, } } +// Metadata returns the provider's metadata, such as its type name, to the Terraform framework. func (p *FrameworkProvider) Metadata( - ctx context.Context, - req provider.MetadataRequest, + _ context.Context, + _ provider.MetadataRequest, resp *provider.MetadataResponse, ) { resp.TypeName = "equinixcloud" } +// Schema returns the provider's schema, which defines the configuration options available to users. func (p *FrameworkProvider) Schema( - ctx context.Context, - req provider.SchemaRequest, + _ context.Context, + _ provider.SchemaRequest, resp *provider.SchemaResponse, ) { resp.Schema = schema.Schema{ @@ -79,6 +85,25 @@ func (p *FrameworkProvider) Schema( int64validator.AtLeast(100), }, }, + "token_exchange_scope": schema.StringAttribute{ + Optional: true, + Description: "The scope of the authentication token. Must be an access policy ERN or a string of the form `roleassignments:`. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SCOPE` shell environment variable. Please note that token exchange is an alpha feature and not available for all users.", + }, + "sts_endpoint": schema.StringAttribute{ + Optional: true, + Description: fmt.Sprintf("The STS API base URL to point to the desired environment. This argument can also be specified with the `EQUINIX_STS_ENDPOINT` shell environment variable. (Defaults to `%s`). Please note that STS is an alpha feature and not available for all users.", config.DefaultStsBaseURL), + Validators: []validator.String{ + equinix_validation.URLWithScheme("http", "https"), + }, + }, + "token_exchange_subject_token": schema.StringAttribute{ + Optional: true, + Description: "The subject token to use for token exchange authentication. Must be an OIDC ID token issued by an OIDC provider trusted by Equinix STS. If not set, the provider will use the environment variable specified in `token_exchange_subject_token_env_var`. Please note that token exchange is an alpha feature and not available for all users.", + }, + "token_exchange_subject_token_env_var": schema.StringAttribute{ + Optional: true, + Description: fmt.Sprintf("The name of the environment variable containing the subject token for token exchange. This argument can also be specified with the `EQUINIX_TOKEN_EXCHANGE_SUBJECT_TOKEN_ENV_VAR` shell environment variable. (Defaults to `%s`). Please note that token exchange is an alpha feature and not available for all users.", config.DefaultTokenExchangeSubjectTokenEnvVar), + }, "max_retries": schema.Int64Attribute{ Optional: true, Description: "Maximum number of retries in case of network failure.", @@ -91,9 +116,10 @@ func (p *FrameworkProvider) Schema( } } +// MetaSchema returns the provider's metadata schema, which defines additional metadata attributes. func (p *FrameworkProvider) MetaSchema( - ctx context.Context, - req provider.MetaSchemaRequest, + _ context.Context, + _ provider.MetaSchemaRequest, resp *provider.MetaSchemaResponse, ) { resp.Schema = metaschema.Schema{ @@ -105,7 +131,8 @@ func (p *FrameworkProvider) MetaSchema( } } -func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { +// Resources returns a list of resource constructors that the provider supports. +func (p *FrameworkProvider) Resources(_ context.Context) []func() resource.Resource { resources := []func() resource.Resource{} resources = append(resources, services.FabricResources()...) resources = append(resources, services.MetalResources()...) @@ -114,7 +141,8 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res return resources } -func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { +// DataSources returns a list of data source constructors that the provider supports. +func (p *FrameworkProvider) DataSources(_ context.Context) []func() datasource.DataSource { datasources := []func() datasource.DataSource{} datasources = append(datasources, services.FabricDatasources()...) datasources = append(datasources, services.MetalDatasources()...) diff --git a/internal/provider/provider_config.go b/internal/provider/provider_config.go index 3f8a39689..2261cebd5 100644 --- a/internal/provider/provider_config.go +++ b/internal/provider/provider_config.go @@ -14,33 +14,43 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) +// FrameworkProviderConfig holds the configuration for the Equinix provider. type FrameworkProviderConfig struct { - BaseURL types.String `tfsdk:"endpoint"` - ClientID types.String `tfsdk:"client_id"` - ClientSecret types.String `tfsdk:"client_secret"` - Token types.String `tfsdk:"token"` - AuthToken types.String `tfsdk:"auth_token"` - RequestTimeout types.Int64 `tfsdk:"request_timeout"` - PageSize types.Int64 `tfsdk:"response_max_page_size"` - MaxRetries types.Int64 `tfsdk:"max_retries"` - MaxRetryWaitSeconds types.Int64 `tfsdk:"max_retry_wait_seconds"` + BaseURL types.String `tfsdk:"endpoint"` + ClientID types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` + Token types.String `tfsdk:"token"` + AuthToken types.String `tfsdk:"auth_token"` + RequestTimeout types.Int64 `tfsdk:"request_timeout"` + PageSize types.Int64 `tfsdk:"response_max_page_size"` + MaxRetries types.Int64 `tfsdk:"max_retries"` + MaxRetryWaitSeconds types.Int64 `tfsdk:"max_retry_wait_seconds"` + TokenExchangeScope types.String `tfsdk:"token_exchange_scope"` + StsBaseURL types.String `tfsdk:"sts_endpoint"` + TokenExchangeSubjectToken types.String `tfsdk:"token_exchange_subject_token"` + TokenExchangeSubjectTokenEnvVar types.String `tfsdk:"token_exchange_subject_token_env_var"` } func (c *FrameworkProviderConfig) toOldStyleConfig() *config.Config { // this immitates func configureProvider in proivder.go return &config.Config{ - AuthToken: c.AuthToken.ValueString(), - BaseURL: c.BaseURL.ValueString(), - ClientID: c.ClientID.ValueString(), - ClientSecret: c.ClientSecret.ValueString(), - Token: c.Token.ValueString(), - RequestTimeout: time.Duration(c.RequestTimeout.ValueInt64()) * time.Second, - PageSize: int(c.PageSize.ValueInt64()), - MaxRetries: int(c.MaxRetries.ValueInt64()), - MaxRetryWait: time.Duration(c.MaxRetryWaitSeconds.ValueInt64()) * time.Second, + AuthToken: c.AuthToken.ValueString(), + BaseURL: c.BaseURL.ValueString(), + ClientID: c.ClientID.ValueString(), + ClientSecret: c.ClientSecret.ValueString(), + Token: c.Token.ValueString(), + RequestTimeout: time.Duration(c.RequestTimeout.ValueInt64()) * time.Second, + PageSize: int(c.PageSize.ValueInt64()), + MaxRetries: int(c.MaxRetries.ValueInt64()), + MaxRetryWait: time.Duration(c.MaxRetryWaitSeconds.ValueInt64()) * time.Second, + TokenExchangeScope: c.TokenExchangeScope.ValueString(), + StsBaseURL: c.StsBaseURL.ValueString(), + TokenExchangeSubjectToken: c.TokenExchangeSubjectToken.ValueString(), + TokenExchangeSubjectTokenEnvVar: c.TokenExchangeSubjectTokenEnvVar.ValueString(), } } +// Configure initializes the provider configuration, reading values from the provider block func (fp *FrameworkProvider) Configure( ctx context.Context, req provider.ConfigureRequest, @@ -84,6 +94,19 @@ func (fp *FrameworkProvider) Configure( fwconfig.MaxRetryWaitSeconds = determineIntConfValue( fwconfig.MaxRetryWaitSeconds, "", 30, &resp.Diagnostics) + + fwconfig.TokenExchangeScope = determineStrConfValue( + fwconfig.TokenExchangeScope, config.TokenExchangeScopeEnvVar, "") + + fwconfig.StsBaseURL = determineStrConfValue( + fwconfig.StsBaseURL, config.StsEndpointEnvVar, config.DefaultStsBaseURL) + + fwconfig.TokenExchangeSubjectToken = determineStrConfValue( + fwconfig.TokenExchangeSubjectToken, "", "") + + fwconfig.TokenExchangeSubjectTokenEnvVar = determineStrConfValue( + fwconfig.TokenExchangeSubjectTokenEnvVar, config.TokenExchangeSubjectTokenEnvVarEnvVar, config.DefaultTokenExchangeSubjectTokenEnvVar) + if resp.Diagnostics.HasError() { return } @@ -103,6 +126,7 @@ func (fp *FrameworkProvider) Configure( fp.Meta = oldStyleConfig } +// GetIntFromEnv retrieves an integer value from the environment variable specified by key. func GetIntFromEnv( key string, defaultValue int64, diff --git a/internal/sts/config.go b/internal/sts/config.go new file mode 100644 index 000000000..b5f71ceb3 --- /dev/null +++ b/internal/sts/config.go @@ -0,0 +1,37 @@ +package sts + +import ( + "sync" + + "github.com/equinix/equinix-sdk-go/services/stsv1alpha" +) + +// Config describes oauth2 client credentials flow +type Config struct { + // ClientID is the application's ID. + StsAuthScope string + // ClientSecret is the application's secret. + StsSourceToken string + // StsBaseURL is the base endpoint of a server that token endpoint + StsBaseURL string +} + +// StsTokenSource returns a TokenSource that returns t until t expires, +// automatically refreshing it as necessary using the provided context and the +// client ID and client secret. +func (c *Config) StsTokenSource() *ContextAwareTokenSource { + config := stsv1alpha.NewConfiguration() + config.Servers = stsv1alpha.ServerConfigurations{ + stsv1alpha.ServerConfiguration{ + URL: c.StsBaseURL, + }, + } + restClient := stsv1alpha.NewAPIClient(config) + source := ContextAwareTokenSource{ + c, + restClient, + sync.Mutex{}, + nil, + } + return &source +} diff --git a/internal/sts/context_aware_token_source.go b/internal/sts/context_aware_token_source.go new file mode 100644 index 000000000..41877644d --- /dev/null +++ b/internal/sts/context_aware_token_source.go @@ -0,0 +1,126 @@ +// Package sts provides a context-aware token source for Equinix STS (Secure Token Service). +package sts + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/equinix/equinix-sdk-go/services/stsv1alpha" + "golang.org/x/oauth2" +) + +// ContextAwareTokenSource Implements the refresh token source +type ContextAwareTokenSource struct { + conf *Config + client *stsv1alpha.APIClient + mu sync.Mutex + token *oauth2.Token +} + +// OidcTokenExchange performs an OIDC token exchange using the configured STS client and settings. +// It ensures thread safety, validates required configuration, and caches the token until expiry. +// Returns a valid OAuth2 token or an error if the exchange fails. +func (s *ContextAwareTokenSource) OidcTokenExchange(ctx context.Context) (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token != nil && s.token.Valid() { + return s.token, nil + } + + if err := s.validateConfig(); err != nil { + return nil, err + } + + token, err := s.executeTokenExchangeWithRetry(ctx) + if err != nil { + return nil, err + } + + s.token = token + return s.token, nil +} + +func (s *ContextAwareTokenSource) executeTokenExchangeWithRetry(ctx context.Context) (*oauth2.Token, error) { + maxRetries := 5 + baseDelay := 100 * time.Millisecond + + for attempt := 0; attempt < maxRetries; attempt++ { + response, httpResp, err := s.client.UseApi.UseTokenPost(ctx). + GrantType(stsv1alpha.USETOKENPOSTREQUESTGRANTTYPE_URN_IETF_PARAMS_OAUTH_GRANT_TYPE_TOKEN_EXCHANGE). + Scope(s.conf.StsAuthScope). + SubjectToken(s.conf.StsSourceToken). + SubjectTokenType(stsv1alpha.USETOKENPOSTREQUESTSUBJECTTOKENTYPE_URN_IETF_PARAMS_OAUTH_TOKEN_TYPE_ID_TOKEN). + Execute() + + if err == nil { + return s.createTokenFromResponse(response) + } + + if !s.shouldRetry(httpResp) { + return nil, s.formatError(httpResp, err) + } + + // Apply backoff delay + delay := time.Duration(1< 0 { + errorMsg += fmt.Sprintf(": %s", string(bodyBytes)) + } + + if err != nil { + errorMsg += fmt.Sprintf(" (underlying error: %v)", err) + } + + return errors.New(errorMsg) +} diff --git a/internal/sts/context_aware_transport.go b/internal/sts/context_aware_transport.go new file mode 100644 index 000000000..4a5bba0fa --- /dev/null +++ b/internal/sts/context_aware_transport.go @@ -0,0 +1,87 @@ +package sts + +import ( + "fmt" + "log" + "net/http" + "sync" +) + +// ContextAwareTransport is an http.RoundTripper that uses a ContextAwareTokenSource +type ContextAwareTransport struct { + // Source supplies the token to add to outgoing requests' + // Authorization headers. + Source *ContextAwareTokenSource + + // Base is the base RoundTripper used to make HTTP requests. + // If nil, http.DefaultTransport is used. + Base http.RoundTripper +} + +// New creates a new ContextAwareTransport using the provided Config. +func (c *Config) New() *ContextAwareTransport { + return &ContextAwareTransport{ + Source: c.StsTokenSource(), + } +} + +// RoundTrip authorizes and authenticates the request with an +// access token from ContextAwareTransport's Source. +func (t *ContextAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + if req.Body != nil { + defer func() { + if !reqBodyClosed { + //nolint:errcheck // Inherited from upstream; disabling lint to avoid a larger refactor + req.Body.Close() + } + }() + } + + // passing in the existing request context + token, err := t.Source.OidcTokenExchange(req.Context()) + if err != nil { + log.Printf("ContextAwareTransport: error during OIDC token exchange: %v", err) + return nil, fmt.Errorf("ContextAwareTransport: OIDC token exchange failed: %w", err) + } + + req2 := cloneRequest(req) // per RoundTripper contract + token.SetAuthHeader(req2) + + // req.Body is assumed to be closed by the base RoundTripper. + reqBodyClosed = true + return t.base().RoundTrip(req2) +} + +var cancelOnce sync.Once + +// CancelRequest does nothing. It used to be a legacy cancellation mechanism +// but now only it only logs on first use to warn that it's deprecated. +// +// Deprecated: use contexts for cancellation instead. +func (t *ContextAwareTransport) CancelRequest(_ *http.Request) { + cancelOnce.Do(func() { + log.Printf("deprecated: golang.org/x/oauth2: ContextAwareTransport.CancelRequest no longer does anything; use contexts") + }) +} + +func (t *ContextAwareTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index b7468c9c3..9386b6fa0 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -8,6 +8,8 @@ The Equinix provider is used to interact with the resources provided by Equinix For information about obtaining API key and secret required for Equinix Fabric and Network Edge refer to [Generating Client ID and Client Secret key](https://developer.equinix.com/dev-docs/fabric/getting-started/getting-access-token#generating-client-id-and-client-secret) from [Equinix Developer Platform portal](https://developer.equinix.com). +Equinix Fabric also supports authentication using a [Workload Identity Token](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens), which can be used in place of the `client_id` and `client_secret` arguments. Requires an authorization scope and OIDC token from an IdP trusted by Equinix STS. Please note that this is an alpha feature not available for all users. Using workload identity tokens will override client ID/secret, you must use [provider aliases](https://developer.hashicorp.com/terraform/language/providers/configuration#alias-multiple-provider-configurations) to manage both workload identity tokens and client ID/secret in a single Terraform configuration. + Interacting with Equinix Metal requires an API auth token that can be generated at [Project-level](https://metal.equinix.com/developers/docs/accounts/projects/#api-keys) or [User-level](https://metal.equinix.com/developers/docs/accounts/users/#api-keys) tokens can be used. If you are only using Equinix Metal resources, you may omit the Client ID and Client Secret provider configuration parameters needed to access other Equinix resource types (Network Edge, Fabric, etc). @@ -24,6 +26,10 @@ Client ID and Client Secret can be omitted when the only Equinix resources consu {{tffile "examples/example_2.tf"}} +Workload Identity Tokens can be used in service authorization scenarios, like HCP Terraform. Other credential variables are optional for `equinix_fabric_*` resources and datasources when using this method. + +{{tffile "examples/example_4.tf"}} + Example provider configuration using `environment variables`: ```sh