Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
88 changes: 88 additions & 0 deletions docs/guides/default_headers.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions examples/provider/default_headers_example.tf
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion internal/provider/curl_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 12 additions & 9 deletions internal/provider/curl_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/curl_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/curl_ephemeral_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()))

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/curl_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down
76 changes: 76 additions & 0 deletions internal/provider/curl_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading