diff --git a/CHANGELOG.md b/CHANGELOG.md index a77de47..0d2f47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.6.0 + +ENHANCEMENTS: + +- Add provider `default_headers` for auth tokens that must be refreshed on each Terraform run, including destroy. Closes #83. + ## 2.5.4 BUG FIXES: diff --git a/docs/guides/default_headers.md b/docs/guides/default_headers.md new file mode 100644 index 0000000..35693bf --- /dev/null +++ b/docs/guides/default_headers.md @@ -0,0 +1,88 @@ +--- +page_title: "Default Headers for Auth Tokens" +subcategory: "Guides" +description: |- + Configure provider-level default headers for short-lived authentication tokens that must stay fresh during destroy. +--- + +# Default Headers for Auth Tokens + +Short-lived authentication tokens (OAuth access tokens, GCP ID tokens, session cookies) often expire between `terraform apply` and a later `terraform destroy`. TerraCurl stores resource header values in Terraform state at apply time. During destroy, the provider reads those stored values—not the freshly evaluated configuration—so destroy requests can fail with `401 Unauthorized` if the token has expired. + +Provider `default_headers` solves this by applying headers from the provider block on **every** outbound HTTP request. Provider configuration is re-evaluated on each Terraform run, including destroy, so dynamic token expressions stay current. + +## When to use default_headers + +Use provider `default_headers` when: + +- Auth tokens expire quickly (for example, GCP Cloud Run ID tokens, ~1 hour TTL) +- The same auth header is needed on create, read, and destroy +- You reference a data source or variable for the token value + +## Example: GCP Cloud Run ID token + +```terraform +data "google_service_account_id_token" "sa_gcp" { + target_service_account = data.google_service_account.sa_id.email + target_audience = "https://my-service.run.app/" +} + +provider "terracurl" { + default_headers = { + "X-Serverless-Authorization" = "Bearer ${data.google_service_account_id_token.sa_gcp.id_token}" + } +} + +resource "terracurl_request" "example" { + name = "example" + url = "https://my-service.run.app/resource" + method = "PUT" + + headers = { + Content-Type = "application/json" + } + + request_body = jsonencode({ id = "example" }) + response_codes = [200] + skip_read = true + + destroy_url = "https://my-service.run.app/resource" + destroy_method = "DELETE" + destroy_response_codes = [200] + destroy_headers = { + Content-Type = "application/json" + } +} +``` + +Move auth headers from `headers` / `destroy_headers` to `default_headers`. Keep non-auth headers (such as `Content-Type`) on the resource if needed. + +## Merge behavior + +TerraCurl applies headers in this order: + +1. Resource-level headers (`headers`, `read_headers`, `destroy_headers`, and so on) +2. Provider `default_headers` (overrides resource headers with the same key) + +Provider headers win on key collision. This ensures a fresh provider token replaces a stale token stored in state during destroy. + +`Host` (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname. + +## Scope + +`default_headers` applies to all outbound TerraCurl requests: + +- `terracurl_request` resources (create, read, destroy) +- `terracurl_request` data sources +- `terracurl_request` actions +- `terracurl_request` ephemeral resources (open, renew, close) + +## Limitations + +- Tokens set only in resource `headers` or `destroy_headers` are still persisted in state. For destroy-only refresh, move auth to `default_headers` or run `terraform apply` before destroy to update state. +- Write-only resource headers (see issue #115) are a separate follow-up to avoid persisting secrets in state entirely. +- `default_headers` is marked sensitive in the provider schema and will not appear in plan output. + +## Workaround without default_headers + +If you cannot upgrade yet, run `terraform apply` (with no infrastructure changes) before `terraform destroy`. That updates header values in state with freshly evaluated tokens, then destroy succeeds. This is fragile when `lifecycle { ignore_changes = ... }` blocks header updates. diff --git a/docs/index.md b/docs/index.md index 7493591..a47b1e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,4 +65,51 @@ provider "terracurl" { } ``` +## Default Headers for Auth Tokens + +TerraCurl supports provider-level `default_headers` for short-lived authentication tokens that must be refreshed on every Terraform run, including destroy. + +See the [Default Headers for Auth Tokens guide](guides/default_headers) for configuration examples, merge behavior, and migration from resource-level auth headers. + +```terraform +# Provider default_headers can be configured in the provider block: +# +# provider "terracurl" { +# default_headers = { +# Authorization = "Bearer ${var.api_token}" +# } +# } +# +# Use default_headers for short-lived auth tokens that must be refreshed on +# every Terraform run, including destroy. Provider headers override resource +# headers with the same key. + +provider "terracurl" { + default_headers = { + Authorization = "Bearer example-token" + } +} + +resource "terracurl_request" "example" { + name = "example" + url = "https://httpbin.org/put" + method = "PUT" + + headers = { + Content-Type = "application/json" + } + + request_body = jsonencode({ id = "example" }) + response_codes = [200] + skip_read = true + + destroy_url = "https://httpbin.org/delete" + destroy_method = "DELETE" + destroy_response_codes = [200] + destroy_headers = { + Content-Type = "application/json" + } +} +``` + ## Limitations diff --git a/examples/provider/default_headers_example.tf b/examples/provider/default_headers_example.tf new file mode 100644 index 0000000..ea9b82a --- /dev/null +++ b/examples/provider/default_headers_example.tf @@ -0,0 +1,38 @@ +# Provider default_headers can be configured in the provider block: +# +# provider "terracurl" { +# default_headers = { +# Authorization = "Bearer ${var.api_token}" +# } +# } +# +# Use default_headers for short-lived auth tokens that must be refreshed on +# every Terraform run, including destroy. Provider headers override resource +# headers with the same key. + +provider "terracurl" { + default_headers = { + Authorization = "Bearer example-token" + } +} + +resource "terracurl_request" "example" { + name = "example" + url = "https://httpbin.org/put" + method = "PUT" + + headers = { + Content-Type = "application/json" + } + + request_body = jsonencode({ id = "example" }) + response_codes = [200] + skip_read = true + + destroy_url = "https://httpbin.org/delete" + destroy_method = "DELETE" + destroy_response_codes = [200] + destroy_headers = { + Content-Type = "application/json" + } +} diff --git a/internal/provider/curl_action.go b/internal/provider/curl_action.go index 66a7a9a..ada2ecf 100644 --- a/internal/provider/curl_action.go +++ b/internal/provider/curl_action.go @@ -169,7 +169,7 @@ func (c *CurlAction) Invoke(ctx context.Context, req action.InvokeRequest, resp return } - applyRequestHeaders(request, data.Headers) + applyRequestHeadersWithDefaults(request, data.Headers, c.providerMeta()) if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() { params := request.URL.Query() diff --git a/internal/provider/curl_action_test.go b/internal/provider/curl_action_test.go index 0f16505..9f7d088 100644 --- a/internal/provider/curl_action_test.go +++ b/internal/provider/curl_action_test.go @@ -92,20 +92,23 @@ func providerWithActions(ctx context.Context, t *testing.T) tfprotov6.ProviderSe providerConfigType := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "http_proxy": tftypes.String, - "https_proxy": tftypes.String, - "no_proxy": tftypes.String, + "http_proxy": tftypes.String, + "https_proxy": tftypes.String, + "no_proxy": tftypes.String, + "default_headers": tftypes.Map{ElementType: tftypes.String}, }, OptionalAttributes: map[string]struct{}{ - "http_proxy": {}, - "https_proxy": {}, - "no_proxy": {}, + "http_proxy": {}, + "https_proxy": {}, + "no_proxy": {}, + "default_headers": {}, }, } providerConfigValue := tftypes.NewValue(providerConfigType, map[string]tftypes.Value{ - "http_proxy": tftypes.NewValue(tftypes.String, nil), - "https_proxy": tftypes.NewValue(tftypes.String, nil), - "no_proxy": tftypes.NewValue(tftypes.String, nil), + "http_proxy": tftypes.NewValue(tftypes.String, nil), + "https_proxy": tftypes.NewValue(tftypes.String, nil), + "no_proxy": tftypes.NewValue(tftypes.String, nil), + "default_headers": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), }) configValue, err := tfprotov6.NewDynamicValue(providerConfigType, providerConfigValue) if err != nil { diff --git a/internal/provider/curl_data_source.go b/internal/provider/curl_data_source.go index 6a7e3a8..97f89dc 100644 --- a/internal/provider/curl_data_source.go +++ b/internal/provider/curl_data_source.go @@ -214,7 +214,7 @@ func (d *CurlDataSource) Read(ctx context.Context, req datasource.ReadRequest, r } // Add headers. - applyRequestHeaders(request, data.Headers) + applyRequestHeadersWithDefaults(request, data.Headers, d.providerMeta()) // Add query parameters. if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() { diff --git a/internal/provider/curl_ephemeral_resource.go b/internal/provider/curl_ephemeral_resource.go index 035e72d..4b87232 100644 --- a/internal/provider/curl_ephemeral_resource.go +++ b/internal/provider/curl_ephemeral_resource.go @@ -453,7 +453,7 @@ func (e *EphemeralCurlResource) Open(ctx context.Context, req ephemeral.OpenRequ } // Add headers. - applyRequestHeaders(request, data.Headers) + applyRequestHeadersWithDefaults(request, data.Headers, e.providerMeta()) // Add query parameters. if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() { @@ -1100,7 +1100,7 @@ func (e *EphemeralCurlResource) Renew(ctx context.Context, req ephemeral.RenewRe } // Add headers - applyRequestHeaders(request, privateData.RenewHeaders) + applyRequestHeadersWithDefaults(request, privateData.RenewHeaders, e.providerMeta()) tflog.Debug(ctx, fmt.Sprintf("Parameters: %v\n", privateData.RenewRequestParameters.Elements())) @@ -1454,7 +1454,7 @@ func (e *EphemeralCurlResource) Close(ctx context.Context, req ephemeral.CloseRe if privateData.CloseHeaders.IsNull() || privateData.CloseHeaders.IsUnknown() { tflog.Debug(ctx, "No CloseHeaders provided, proceeding without headers") } else { - applyRequestHeaders(request, privateData.CloseHeaders) + applyRequestHeadersWithDefaults(request, privateData.CloseHeaders, e.providerMeta()) } // Add Query Parameters diff --git a/internal/provider/curl_resource.go b/internal/provider/curl_resource.go index c5089bf..bb9c55c 100644 --- a/internal/provider/curl_resource.go +++ b/internal/provider/curl_resource.go @@ -515,7 +515,7 @@ func (r *CurlResource) Create(ctx context.Context, req resource.CreateRequest, r } // Add headers - applyRequestHeaders(request, data.Headers) + applyRequestHeadersWithDefaults(request, data.Headers, r.providerMeta()) // Add query parameters if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() { @@ -646,7 +646,7 @@ func (r *CurlResource) executeReadRequest(ctx context.Context, data CurlResource return } - applyRequestHeaders(request, data.ReadHeaders) + applyRequestHeadersWithDefaults(request, data.ReadHeaders, r.providerMeta()) if !data.ReadParameters.IsNull() && !data.ReadParameters.IsUnknown() { params := request.URL.Query() @@ -873,7 +873,7 @@ func (r *CurlResource) Delete(ctx context.Context, req resource.DeleteRequest, r } // Add Headers - applyRequestHeaders(request, data.DestroyHeaders) + applyRequestHeadersWithDefaults(request, data.DestroyHeaders, r.providerMeta()) // Add Query Parameters if !data.DestroyRequestParameters.IsNull() && !data.DestroyRequestParameters.IsUnknown() { diff --git a/internal/provider/curl_resource_test.go b/internal/provider/curl_resource_test.go index cdc225b..57f0173 100644 --- a/internal/provider/curl_resource_test.go +++ b/internal/provider/curl_resource_test.go @@ -1887,3 +1887,79 @@ func TestCurlResource_StateUpgrade_NilRequestState(t *testing.T) { t.Error("Expected response_sensitive to default to false") } } + +func TestCurlResource_Delete_ProviderDefaultHeadersOverridesStaleState(t *testing.T) { + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedAuth string + httpmock.RegisterResponder( + "DELETE", + "https://example.com/destroy", + func(req *http.Request) (*http.Response, error) { + receivedAuth = req.Header.Get("Authorization") + return httpmock.NewStringResponse(200, `{"deleted":true}`), nil + }, + ) + + ctx := context.Background() + providerHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer fresh"), + }) + meta := NewProviderMeta( + types.StringNull(), + types.StringNull(), + types.StringNull(), + providerHeaders, + ) + r := &CurlResource{meta: meta} + + schemaResp := &resource2.SchemaResponse{} + r.Schema(ctx, resource2.SchemaRequest{}, schemaResp) + + stateModel := CurlResourceModel{ + Id: types.StringValue("test"), + Name: types.StringValue("test"), + Url: types.StringValue("https://example.com/create"), + Method: types.StringValue("POST"), + SkipRead: types.BoolValue(true), + SkipDestroy: types.BoolValue(false), + DestroyUrl: types.StringValue("https://example.com/destroy"), + DestroyMethod: types.StringValue("DELETE"), + DestroyResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + DestroyHeaders: types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer stale"), + "Content-Type": types.StringValue("application/json"), + }), + DestroyTimeout: types.Int64Value(10), + DestroyRetryInterval: types.Int64Value(1), + DestroyMaxRetry: types.Int64Value(0), + ResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + ReadResponseCodes: types.ListNull(types.StringType), + IgnoreResponseFields: types.ListNull(types.StringType), + Headers: types.MapNull(types.StringType), + RequestParameters: types.MapNull(types.StringType), + ReadHeaders: types.MapNull(types.StringType), + ReadParameters: types.MapNull(types.StringType), + DestroyRequestParameters: types.MapNull(types.StringType), + } + + state := tfsdk.State{Schema: schemaResp.Schema} + if diags := state.Set(ctx, &stateModel); diags.HasError() { + t.Fatalf("failed to set state: %v", diags) + } + + deleteResp := &resource2.DeleteResponse{ + State: state, + } + r.Delete(ctx, resource2.DeleteRequest{State: state}, deleteResp) + if deleteResp.Diagnostics.HasError() { + t.Fatalf("Delete failed: %v", deleteResp.Diagnostics) + } + + if receivedAuth != "Bearer fresh" { + t.Fatalf("expected destroy to use fresh provider Authorization header, got %q", receivedAuth) + } +} diff --git a/internal/provider/default_headers_test.go b/internal/provider/default_headers_test.go new file mode 100644 index 0000000..9a45113 --- /dev/null +++ b/internal/provider/default_headers_test.go @@ -0,0 +1,124 @@ +package provider + +import ( + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNewProviderMetaDefaultHeaders(t *testing.T) { + headers := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer fresh"), + "X-Custom": types.StringValue("value"), + }) + + meta := NewProviderMeta( + types.StringNull(), + types.StringNull(), + types.StringNull(), + headers, + ) + + got := meta.DefaultHeaders() + if got["Authorization"] != "Bearer fresh" { + t.Fatalf("expected Authorization header, got %q", got["Authorization"]) + } + if got["X-Custom"] != "value" { + t.Fatalf("expected X-Custom header, got %q", got["X-Custom"]) + } +} + +func TestNewProviderMetaNullDefaultHeaders(t *testing.T) { + meta := NewProviderMeta( + types.StringNull(), + types.StringNull(), + types.StringNull(), + types.MapNull(types.StringType), + ) + + if meta.DefaultHeaders() != nil { + t.Fatalf("expected nil default headers, got %v", meta.DefaultHeaders()) + } +} + +func TestApplyRequestHeadersWithDefaultsOverridesResourceHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodDelete, "https://example.com/resource", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resourceHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer stale"), + "Content-Type": types.StringValue("application/json"), + }) + providerHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer fresh"), + }) + meta := NewProviderMeta( + types.StringNull(), + types.StringNull(), + types.StringNull(), + providerHeaders, + ) + + applyRequestHeadersWithDefaults(req, resourceHeaders, meta) + + if got := req.Header.Get("Authorization"); got != "Bearer fresh" { + t.Fatalf("expected provider Authorization to override stale value, got %q", got) + } + if got := req.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected Content-Type from resource headers, got %q", got) + } +} + +func TestApplyRequestHeadersWithDefaultsHostOverride(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/path", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resourceHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "host": types.StringValue("stale.example.com"), + }) + providerHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Host": types.StringValue("fresh.example.com"), + }) + meta := NewProviderMeta( + types.StringNull(), + types.StringNull(), + types.StringNull(), + providerHeaders, + ) + + applyRequestHeadersWithDefaults(req, resourceHeaders, meta) + + if req.Host != "fresh.example.com" { + t.Fatalf("expected provider Host override, got %q", req.Host) + } +} + +func TestApplyRequestHeadersWithDefaultsNilMeta(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resourceHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{ + "Authorization": types.StringValue("Bearer stale"), + }) + + applyRequestHeadersWithDefaults(req, resourceHeaders, nil) + + if got := req.Header.Get("Authorization"); got != "Bearer stale" { + t.Fatalf("expected resource Authorization unchanged, got %q", got) + } +} + +func TestDefaultProviderMetaHasNoDefaultHeaders(t *testing.T) { + meta := DefaultProviderMeta() + if meta.DefaultHeaders() != nil { + t.Fatalf("expected no default headers, got %v", meta.DefaultHeaders()) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 558e8b6..0609a74 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -30,9 +30,10 @@ type TerraCurlProvider struct { // TerraCurlProviderModel describes the provider data model. type TerraCurlProviderModel struct { - HttpProxy types.String `tfsdk:"http_proxy"` - HttpsProxy types.String `tfsdk:"https_proxy"` - NoProxy types.String `tfsdk:"no_proxy"` + HttpProxy types.String `tfsdk:"http_proxy"` + HttpsProxy types.String `tfsdk:"https_proxy"` + NoProxy types.String `tfsdk:"no_proxy"` + DefaultHeaders types.Map `tfsdk:"default_headers"` } func (p *TerraCurlProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { @@ -56,6 +57,12 @@ func (p *TerraCurlProvider) Schema(ctx context.Context, req provider.SchemaReque MarkdownDescription: "Comma-separated list of hosts that should bypass the proxy. Overrides the `NO_PROXY` environment variable when set.", Optional: true, }, + "default_headers": schema.MapAttribute{ + MarkdownDescription: "Headers applied to every outbound HTTP request. Values are re-evaluated on each Terraform run and override resource-level headers with the same key. Use for short-lived auth tokens (e.g. OAuth, GCP ID tokens) that must stay fresh during destroy.", + ElementType: types.StringType, + Optional: true, + Sensitive: true, + }, }, } } @@ -69,7 +76,7 @@ func (p *TerraCurlProvider) Configure(ctx context.Context, req provider.Configur return } - meta := NewProviderMeta(data.HttpProxy, data.HttpsProxy, data.NoProxy) + meta := NewProviderMeta(data.HttpProxy, data.HttpsProxy, data.NoProxy, data.DefaultHeaders) resp.DataSourceData = meta resp.ResourceData = meta resp.EphemeralResourceData = meta diff --git a/internal/provider/proxy_test.go b/internal/provider/proxy_test.go index 29e4eb2..bbac1f0 100644 --- a/internal/provider/proxy_test.go +++ b/internal/provider/proxy_test.go @@ -16,7 +16,7 @@ func TestNewProviderMetaProxyResolution(t *testing.T) { t.Setenv("NO_PROXY", "") t.Run("uses environment when provider values unset", func(t *testing.T) { - meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringNull()) + meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringNull(), types.MapNull(types.StringType)) proxyURL, err := meta.requestProxy(&http.Request{URL: mustParseURL(t, "http://example.com/path")}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -31,6 +31,7 @@ func TestNewProviderMetaProxyResolution(t *testing.T) { types.StringValue("http://provider-proxy.example:3128"), types.StringNull(), types.StringNull(), + types.MapNull(types.StringType), ) proxyURL, err := meta.requestProxy(&http.Request{URL: mustParseURL(t, "http://example.com/path")}) if err != nil { @@ -42,7 +43,7 @@ func TestNewProviderMetaProxyResolution(t *testing.T) { }) t.Run("explicit empty provider value disables proxy", func(t *testing.T) { - meta := NewProviderMeta(types.StringValue(""), types.StringNull(), types.StringNull()) + meta := NewProviderMeta(types.StringValue(""), types.StringNull(), types.StringNull(), types.MapNull(types.StringType)) proxyURL, err := meta.requestProxy(&http.Request{URL: mustParseURL(t, "http://example.com/path")}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -53,7 +54,7 @@ func TestNewProviderMetaProxyResolution(t *testing.T) { }) t.Run("uses HTTPS proxy for HTTPS requests", func(t *testing.T) { - meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringNull()) + meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringNull(), types.MapNull(types.StringType)) proxyURL, err := meta.requestProxy(&http.Request{URL: mustParseURL(t, "https://example.com/path")}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -64,7 +65,7 @@ func TestNewProviderMetaProxyResolution(t *testing.T) { }) t.Run("respects NO_PROXY", func(t *testing.T) { - meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringValue("localhost,127.0.0.1")) + meta := NewProviderMeta(types.StringNull(), types.StringNull(), types.StringValue("localhost,127.0.0.1"), types.MapNull(types.StringType)) proxyURL, err := meta.requestProxy(&http.Request{URL: mustParseURL(t, "http://127.0.0.1/path")}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -80,6 +81,7 @@ func TestProviderMetaNewHTTPClientTransportProxy(t *testing.T) { types.StringValue("http://proxy.example:8080"), types.StringNull(), types.StringNull(), + types.MapNull(types.StringType), ) t.Run("non-TLS client configures proxy", func(t *testing.T) { @@ -126,6 +128,7 @@ func TestHTTPClientRoutesHTTPThroughProxy(t *testing.T) { types.StringValue(proxyServer.URL), types.StringNull(), types.StringValue(""), + types.MapNull(types.StringType), ) client, err := meta.NewHTTPClient(nil) if err != nil { @@ -171,6 +174,7 @@ func TestHTTPClientRoutesHTTPSThroughProxy(t *testing.T) { types.StringNull(), types.StringValue(proxyServer.URL), types.StringValue(""), + types.MapNull(types.StringType), ) client, err := meta.NewHTTPClient(&TlsConfig{SkipTlsVerify: true}) if err != nil { diff --git a/internal/provider/utilities.go b/internal/provider/utilities.go index f3e646c..90f5df0 100644 --- a/internal/provider/utilities.go +++ b/internal/provider/utilities.go @@ -164,7 +164,8 @@ func defaultTlsConfig() *TlsConfig { // ProviderMeta carries provider-level configuration passed to resources and data sources. type ProviderMeta struct { - proxyFunc func(*url.URL) (*url.URL, error) + proxyFunc func(*url.URL) (*url.URL, error) + defaultHeaders map[string]string } // DefaultProviderMeta returns provider metadata that uses only environment-based proxy settings. @@ -176,7 +177,7 @@ func DefaultProviderMeta() *ProviderMeta { // NewProviderMeta builds provider metadata, merging optional provider proxy settings with // environment variables. Explicitly set provider attributes override environment values. -func NewProviderMeta(httpProxy, httpsProxy, noProxy types.String) *ProviderMeta { +func NewProviderMeta(httpProxy, httpsProxy, noProxy types.String, defaultHeaders types.Map) *ProviderMeta { cfg := httpproxy.FromEnvironment() if !httpProxy.IsNull() { cfg.HTTPProxy = httpProxy.ValueString() @@ -187,7 +188,18 @@ func NewProviderMeta(httpProxy, httpsProxy, noProxy types.String) *ProviderMeta if !noProxy.IsNull() { cfg.NoProxy = noProxy.ValueString() } - return &ProviderMeta{proxyFunc: cfg.ProxyFunc()} + return &ProviderMeta{ + proxyFunc: cfg.ProxyFunc(), + defaultHeaders: convertMap(defaultHeaders), + } +} + +// DefaultHeaders returns provider-level headers applied to every outbound request. +func (m *ProviderMeta) DefaultHeaders() map[string]string { + if m == nil || len(m.defaultHeaders) == 0 { + return nil + } + return m.defaultHeaders } func (m *ProviderMeta) requestProxy(req *http.Request) (*url.URL, error) { @@ -322,11 +334,25 @@ func applyRequestHeaders(req *http.Request, headers types.Map) { for k, v := range headers.Elements() { if strVal, ok := v.(types.String); ok { - if strings.EqualFold(k, "host") { - req.Host = strVal.ValueString() - continue - } - req.Header.Set(k, strVal.ValueString()) + setRequestHeader(req, k, strVal.ValueString()) } } } + +func setRequestHeader(req *http.Request, key, value string) { + if strings.EqualFold(key, "host") { + req.Host = value + return + } + req.Header.Set(key, value) +} + +func applyRequestHeadersWithDefaults(req *http.Request, headers types.Map, meta *ProviderMeta) { + applyRequestHeaders(req, headers) + if meta == nil { + return + } + for k, v := range meta.DefaultHeaders() { + setRequestHeader(req, k, v) + } +} diff --git a/templates/guides/default_headers.md.tmpl b/templates/guides/default_headers.md.tmpl new file mode 100644 index 0000000..35693bf --- /dev/null +++ b/templates/guides/default_headers.md.tmpl @@ -0,0 +1,88 @@ +--- +page_title: "Default Headers for Auth Tokens" +subcategory: "Guides" +description: |- + Configure provider-level default headers for short-lived authentication tokens that must stay fresh during destroy. +--- + +# Default Headers for Auth Tokens + +Short-lived authentication tokens (OAuth access tokens, GCP ID tokens, session cookies) often expire between `terraform apply` and a later `terraform destroy`. TerraCurl stores resource header values in Terraform state at apply time. During destroy, the provider reads those stored values—not the freshly evaluated configuration—so destroy requests can fail with `401 Unauthorized` if the token has expired. + +Provider `default_headers` solves this by applying headers from the provider block on **every** outbound HTTP request. Provider configuration is re-evaluated on each Terraform run, including destroy, so dynamic token expressions stay current. + +## When to use default_headers + +Use provider `default_headers` when: + +- Auth tokens expire quickly (for example, GCP Cloud Run ID tokens, ~1 hour TTL) +- The same auth header is needed on create, read, and destroy +- You reference a data source or variable for the token value + +## Example: GCP Cloud Run ID token + +```terraform +data "google_service_account_id_token" "sa_gcp" { + target_service_account = data.google_service_account.sa_id.email + target_audience = "https://my-service.run.app/" +} + +provider "terracurl" { + default_headers = { + "X-Serverless-Authorization" = "Bearer ${data.google_service_account_id_token.sa_gcp.id_token}" + } +} + +resource "terracurl_request" "example" { + name = "example" + url = "https://my-service.run.app/resource" + method = "PUT" + + headers = { + Content-Type = "application/json" + } + + request_body = jsonencode({ id = "example" }) + response_codes = [200] + skip_read = true + + destroy_url = "https://my-service.run.app/resource" + destroy_method = "DELETE" + destroy_response_codes = [200] + destroy_headers = { + Content-Type = "application/json" + } +} +``` + +Move auth headers from `headers` / `destroy_headers` to `default_headers`. Keep non-auth headers (such as `Content-Type`) on the resource if needed. + +## Merge behavior + +TerraCurl applies headers in this order: + +1. Resource-level headers (`headers`, `read_headers`, `destroy_headers`, and so on) +2. Provider `default_headers` (overrides resource headers with the same key) + +Provider headers win on key collision. This ensures a fresh provider token replaces a stale token stored in state during destroy. + +`Host` (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname. + +## Scope + +`default_headers` applies to all outbound TerraCurl requests: + +- `terracurl_request` resources (create, read, destroy) +- `terracurl_request` data sources +- `terracurl_request` actions +- `terracurl_request` ephemeral resources (open, renew, close) + +## Limitations + +- Tokens set only in resource `headers` or `destroy_headers` are still persisted in state. For destroy-only refresh, move auth to `default_headers` or run `terraform apply` before destroy to update state. +- Write-only resource headers (see issue #115) are a separate follow-up to avoid persisting secrets in state entirely. +- `default_headers` is marked sensitive in the provider schema and will not appear in plan output. + +## Workaround without default_headers + +If you cannot upgrade yet, run `terraform apply` (with no infrastructure changes) before `terraform destroy`. That updates header values in state with freshly evaluated tokens, then destroy succeeds. This is fragile when `lifecycle { ignore_changes = ... }` blocks header updates. diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index c2fc5da..aa6b18d 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -34,4 +34,12 @@ See the [HTTP Proxy Support guide](guides/proxy) for configuration examples, pre {{ tffile "examples/provider/proxy_example.tf" }} +## Default Headers for Auth Tokens + +TerraCurl supports provider-level `default_headers` for short-lived authentication tokens that must be refreshed on every Terraform run, including destroy. + +See the [Default Headers for Auth Tokens guide](guides/default_headers) for configuration examples, merge behavior, and migration from resource-level auth headers. + +{{ tffile "examples/provider/default_headers_example.tf" }} + ## Limitations