Skip to content

Commit 762a733

Browse files
Add provider default_headers for fresh auth on destroy.
Provider-level headers are re-evaluated on every Terraform run and override stale resource header values during destroy, fixing expired token failures. Closes #83 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1690def commit 762a733

16 files changed

Lines changed: 548 additions & 33 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.6.0
2+
3+
ENHANCEMENTS:
4+
5+
- Add provider `default_headers` for auth tokens that must be refreshed on each Terraform run, including destroy. Closes #83.
6+
17
## 2.5.4
28

39
BUG FIXES:

docs/guides/default_headers.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
page_title: "Default Headers for Auth Tokens"
3+
subcategory: "Guides"
4+
description: |-
5+
Configure provider-level default headers for short-lived authentication tokens that must stay fresh during destroy.
6+
---
7+
8+
# Default Headers for Auth Tokens
9+
10+
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.
11+
12+
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.
13+
14+
## When to use default_headers
15+
16+
Use provider `default_headers` when:
17+
18+
- Auth tokens expire quickly (for example, GCP Cloud Run ID tokens, ~1 hour TTL)
19+
- The same auth header is needed on create, read, and destroy
20+
- You reference a data source or variable for the token value
21+
22+
## Example: GCP Cloud Run ID token
23+
24+
```terraform
25+
data "google_service_account_id_token" "sa_gcp" {
26+
target_service_account = data.google_service_account.sa_id.email
27+
target_audience = "https://my-service.run.app/"
28+
}
29+
30+
provider "terracurl" {
31+
default_headers = {
32+
"X-Serverless-Authorization" = "Bearer ${data.google_service_account_id_token.sa_gcp.id_token}"
33+
}
34+
}
35+
36+
resource "terracurl_request" "example" {
37+
name = "example"
38+
url = "https://my-service.run.app/resource"
39+
method = "PUT"
40+
41+
headers = {
42+
Content-Type = "application/json"
43+
}
44+
45+
request_body = jsonencode({ id = "example" })
46+
response_codes = [200]
47+
skip_read = true
48+
49+
destroy_url = "https://my-service.run.app/resource"
50+
destroy_method = "DELETE"
51+
destroy_response_codes = [200]
52+
destroy_headers = {
53+
Content-Type = "application/json"
54+
}
55+
}
56+
```
57+
58+
Move auth headers from `headers` / `destroy_headers` to `default_headers`. Keep non-auth headers (such as `Content-Type`) on the resource if needed.
59+
60+
## Merge behavior
61+
62+
TerraCurl applies headers in this order:
63+
64+
1. Resource-level headers (`headers`, `read_headers`, `destroy_headers`, and so on)
65+
2. Provider `default_headers` (overrides resource headers with the same key)
66+
67+
Provider headers win on key collision. This ensures a fresh provider token replaces a stale token stored in state during destroy.
68+
69+
`Host` (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname.
70+
71+
## Scope
72+
73+
`default_headers` applies to all outbound TerraCurl requests:
74+
75+
- `terracurl_request` resources (create, read, destroy)
76+
- `terracurl_request` data sources
77+
- `terracurl_request` actions
78+
- `terracurl_request` ephemeral resources (open, renew, close)
79+
80+
## Limitations
81+
82+
- 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.
83+
- Write-only resource headers (see issue #115) are a separate follow-up to avoid persisting secrets in state entirely.
84+
- `default_headers` is marked sensitive in the provider schema and will not appear in plan output.
85+
86+
## Workaround without default_headers
87+
88+
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.

docs/index.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,51 @@ provider "terracurl" {
6565
}
6666
```
6767

68+
## Default Headers for Auth Tokens
69+
70+
TerraCurl supports provider-level `default_headers` for short-lived authentication tokens that must be refreshed on every Terraform run, including destroy.
71+
72+
See the [Default Headers for Auth Tokens guide](guides/default_headers) for configuration examples, merge behavior, and migration from resource-level auth headers.
73+
74+
```terraform
75+
# Provider default_headers can be configured in the provider block:
76+
#
77+
# provider "terracurl" {
78+
# default_headers = {
79+
# Authorization = "Bearer ${var.api_token}"
80+
# }
81+
# }
82+
#
83+
# Use default_headers for short-lived auth tokens that must be refreshed on
84+
# every Terraform run, including destroy. Provider headers override resource
85+
# headers with the same key.
86+
87+
provider "terracurl" {
88+
default_headers = {
89+
Authorization = "Bearer example-token"
90+
}
91+
}
92+
93+
resource "terracurl_request" "example" {
94+
name = "example"
95+
url = "https://httpbin.org/put"
96+
method = "PUT"
97+
98+
headers = {
99+
Content-Type = "application/json"
100+
}
101+
102+
request_body = jsonencode({ id = "example" })
103+
response_codes = [200]
104+
skip_read = true
105+
106+
destroy_url = "https://httpbin.org/delete"
107+
destroy_method = "DELETE"
108+
destroy_response_codes = [200]
109+
destroy_headers = {
110+
Content-Type = "application/json"
111+
}
112+
}
113+
```
114+
68115
## Limitations
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Provider default_headers can be configured in the provider block:
2+
#
3+
# provider "terracurl" {
4+
# default_headers = {
5+
# Authorization = "Bearer ${var.api_token}"
6+
# }
7+
# }
8+
#
9+
# Use default_headers for short-lived auth tokens that must be refreshed on
10+
# every Terraform run, including destroy. Provider headers override resource
11+
# headers with the same key.
12+
13+
provider "terracurl" {
14+
default_headers = {
15+
Authorization = "Bearer example-token"
16+
}
17+
}
18+
19+
resource "terracurl_request" "example" {
20+
name = "example"
21+
url = "https://httpbin.org/put"
22+
method = "PUT"
23+
24+
headers = {
25+
Content-Type = "application/json"
26+
}
27+
28+
request_body = jsonencode({ id = "example" })
29+
response_codes = [200]
30+
skip_read = true
31+
32+
destroy_url = "https://httpbin.org/delete"
33+
destroy_method = "DELETE"
34+
destroy_response_codes = [200]
35+
destroy_headers = {
36+
Content-Type = "application/json"
37+
}
38+
}

internal/provider/curl_action.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func (c *CurlAction) Invoke(ctx context.Context, req action.InvokeRequest, resp
169169
return
170170
}
171171

172-
applyRequestHeaders(request, data.Headers)
172+
applyRequestHeadersWithDefaults(request, data.Headers, c.providerMeta())
173173

174174
if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() {
175175
params := request.URL.Query()

internal/provider/curl_action_test.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,23 @@ func providerWithActions(ctx context.Context, t *testing.T) tfprotov6.ProviderSe
9292

9393
providerConfigType := tftypes.Object{
9494
AttributeTypes: map[string]tftypes.Type{
95-
"http_proxy": tftypes.String,
96-
"https_proxy": tftypes.String,
97-
"no_proxy": tftypes.String,
95+
"http_proxy": tftypes.String,
96+
"https_proxy": tftypes.String,
97+
"no_proxy": tftypes.String,
98+
"default_headers": tftypes.Map{ElementType: tftypes.String},
9899
},
99100
OptionalAttributes: map[string]struct{}{
100-
"http_proxy": {},
101-
"https_proxy": {},
102-
"no_proxy": {},
101+
"http_proxy": {},
102+
"https_proxy": {},
103+
"no_proxy": {},
104+
"default_headers": {},
103105
},
104106
}
105107
providerConfigValue := tftypes.NewValue(providerConfigType, map[string]tftypes.Value{
106-
"http_proxy": tftypes.NewValue(tftypes.String, nil),
107-
"https_proxy": tftypes.NewValue(tftypes.String, nil),
108-
"no_proxy": tftypes.NewValue(tftypes.String, nil),
108+
"http_proxy": tftypes.NewValue(tftypes.String, nil),
109+
"https_proxy": tftypes.NewValue(tftypes.String, nil),
110+
"no_proxy": tftypes.NewValue(tftypes.String, nil),
111+
"default_headers": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil),
109112
})
110113
configValue, err := tfprotov6.NewDynamicValue(providerConfigType, providerConfigValue)
111114
if err != nil {

internal/provider/curl_data_source.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (d *CurlDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
214214
}
215215

216216
// Add headers.
217-
applyRequestHeaders(request, data.Headers)
217+
applyRequestHeadersWithDefaults(request, data.Headers, d.providerMeta())
218218

219219
// Add query parameters.
220220
if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() {

internal/provider/curl_ephemeral_resource.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ func (e *EphemeralCurlResource) Open(ctx context.Context, req ephemeral.OpenRequ
453453
}
454454

455455
// Add headers.
456-
applyRequestHeaders(request, data.Headers)
456+
applyRequestHeadersWithDefaults(request, data.Headers, e.providerMeta())
457457

458458
// Add query parameters.
459459
if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() {
@@ -1100,7 +1100,7 @@ func (e *EphemeralCurlResource) Renew(ctx context.Context, req ephemeral.RenewRe
11001100
}
11011101

11021102
// Add headers
1103-
applyRequestHeaders(request, privateData.RenewHeaders)
1103+
applyRequestHeadersWithDefaults(request, privateData.RenewHeaders, e.providerMeta())
11041104

11051105
tflog.Debug(ctx, fmt.Sprintf("Parameters: %v\n", privateData.RenewRequestParameters.Elements()))
11061106

@@ -1454,7 +1454,7 @@ func (e *EphemeralCurlResource) Close(ctx context.Context, req ephemeral.CloseRe
14541454
if privateData.CloseHeaders.IsNull() || privateData.CloseHeaders.IsUnknown() {
14551455
tflog.Debug(ctx, "No CloseHeaders provided, proceeding without headers")
14561456
} else {
1457-
applyRequestHeaders(request, privateData.CloseHeaders)
1457+
applyRequestHeadersWithDefaults(request, privateData.CloseHeaders, e.providerMeta())
14581458
}
14591459

14601460
// Add Query Parameters

internal/provider/curl_resource.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ func (r *CurlResource) Create(ctx context.Context, req resource.CreateRequest, r
515515
}
516516

517517
// Add headers
518-
applyRequestHeaders(request, data.Headers)
518+
applyRequestHeadersWithDefaults(request, data.Headers, r.providerMeta())
519519

520520
// Add query parameters
521521
if !data.RequestParameters.IsNull() && !data.RequestParameters.IsUnknown() {
@@ -646,7 +646,7 @@ func (r *CurlResource) executeReadRequest(ctx context.Context, data CurlResource
646646
return
647647
}
648648

649-
applyRequestHeaders(request, data.ReadHeaders)
649+
applyRequestHeadersWithDefaults(request, data.ReadHeaders, r.providerMeta())
650650

651651
if !data.ReadParameters.IsNull() && !data.ReadParameters.IsUnknown() {
652652
params := request.URL.Query()
@@ -873,7 +873,7 @@ func (r *CurlResource) Delete(ctx context.Context, req resource.DeleteRequest, r
873873
}
874874

875875
// Add Headers
876-
applyRequestHeaders(request, data.DestroyHeaders)
876+
applyRequestHeadersWithDefaults(request, data.DestroyHeaders, r.providerMeta())
877877

878878
// Add Query Parameters
879879
if !data.DestroyRequestParameters.IsNull() && !data.DestroyRequestParameters.IsUnknown() {

internal/provider/curl_resource_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,3 +1887,79 @@ func TestCurlResource_StateUpgrade_NilRequestState(t *testing.T) {
18871887
t.Error("Expected response_sensitive to default to false")
18881888
}
18891889
}
1890+
1891+
func TestCurlResource_Delete_ProviderDefaultHeadersOverridesStaleState(t *testing.T) {
1892+
t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true")
1893+
1894+
httpmock.Activate()
1895+
defer httpmock.DeactivateAndReset()
1896+
1897+
var receivedAuth string
1898+
httpmock.RegisterResponder(
1899+
"DELETE",
1900+
"https://example.com/destroy",
1901+
func(req *http.Request) (*http.Response, error) {
1902+
receivedAuth = req.Header.Get("Authorization")
1903+
return httpmock.NewStringResponse(200, `{"deleted":true}`), nil
1904+
},
1905+
)
1906+
1907+
ctx := context.Background()
1908+
providerHeaders := types.MapValueMust(types.StringType, map[string]attr.Value{
1909+
"Authorization": types.StringValue("Bearer fresh"),
1910+
})
1911+
meta := NewProviderMeta(
1912+
types.StringNull(),
1913+
types.StringNull(),
1914+
types.StringNull(),
1915+
providerHeaders,
1916+
)
1917+
r := &CurlResource{meta: meta}
1918+
1919+
schemaResp := &resource2.SchemaResponse{}
1920+
r.Schema(ctx, resource2.SchemaRequest{}, schemaResp)
1921+
1922+
stateModel := CurlResourceModel{
1923+
Id: types.StringValue("test"),
1924+
Name: types.StringValue("test"),
1925+
Url: types.StringValue("https://example.com/create"),
1926+
Method: types.StringValue("POST"),
1927+
SkipRead: types.BoolValue(true),
1928+
SkipDestroy: types.BoolValue(false),
1929+
DestroyUrl: types.StringValue("https://example.com/destroy"),
1930+
DestroyMethod: types.StringValue("DELETE"),
1931+
DestroyResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}),
1932+
DestroyHeaders: types.MapValueMust(types.StringType, map[string]attr.Value{
1933+
"Authorization": types.StringValue("Bearer stale"),
1934+
"Content-Type": types.StringValue("application/json"),
1935+
}),
1936+
DestroyTimeout: types.Int64Value(10),
1937+
DestroyRetryInterval: types.Int64Value(1),
1938+
DestroyMaxRetry: types.Int64Value(0),
1939+
ResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}),
1940+
ReadResponseCodes: types.ListNull(types.StringType),
1941+
IgnoreResponseFields: types.ListNull(types.StringType),
1942+
Headers: types.MapNull(types.StringType),
1943+
RequestParameters: types.MapNull(types.StringType),
1944+
ReadHeaders: types.MapNull(types.StringType),
1945+
ReadParameters: types.MapNull(types.StringType),
1946+
DestroyRequestParameters: types.MapNull(types.StringType),
1947+
}
1948+
1949+
state := tfsdk.State{Schema: schemaResp.Schema}
1950+
if diags := state.Set(ctx, &stateModel); diags.HasError() {
1951+
t.Fatalf("failed to set state: %v", diags)
1952+
}
1953+
1954+
deleteResp := &resource2.DeleteResponse{
1955+
State: state,
1956+
}
1957+
r.Delete(ctx, resource2.DeleteRequest{State: state}, deleteResp)
1958+
if deleteResp.Diagnostics.HasError() {
1959+
t.Fatalf("Delete failed: %v", deleteResp.Diagnostics)
1960+
}
1961+
1962+
if receivedAuth != "Bearer fresh" {
1963+
t.Fatalf("expected destroy to use fresh provider Authorization header, got %q", receivedAuth)
1964+
}
1965+
}

0 commit comments

Comments
 (0)