From ed48f7484f77cf304c2e9103f61f717a6b1a3992 Mon Sep 17 00:00:00 2001 From: devops-rob Date: Mon, 20 Jul 2026 17:26:41 +0100 Subject: [PATCH 1/2] Add response_sensitive support for resource, data source, and ephemeral. Port optional sensitive response attributes from #144 onto current main, integrate with Create/Read sanitization from #154, and extend coverage to data source and ephemeral open/renew/close responses. Supersedes #144. Closes #142. Co-authored-by: Dan Hansen Co-authored-by: Cursor --- CHANGELOG.md | 6 + docs/data-sources/request.md | 4 +- docs/ephemeral-resources/request.md | 10 +- docs/resources/request.md | 4 +- internal/provider/curl_data_source.go | 16 +- internal/provider/curl_data_source_test.go | 86 ++++++ internal/provider/curl_ephemeral_resource.go | 54 +++- .../provider/curl_ephemeral_resource_test.go | 114 ++++++++ internal/provider/curl_resource.go | 39 ++- internal/provider/curl_resource_test.go | 253 ++++++++++++++++++ internal/provider/utilities.go | 72 +++++ 11 files changed, 636 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 821c6e1..5e2982f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.5.0 + +FEATURES: + +- Add optional `response_sensitive` and sensitive response attributes to `terracurl_request` resource, data source, and ephemeral resource to prevent secret values appearing in plan output. Based on #144 by @ohaibbq. Closes #142. + ## 2.4.2 BUG FIXES: diff --git a/docs/data-sources/request.md b/docs/data-sources/request.md index 526de59..56a0ba3 100644 --- a/docs/data-sources/request.md +++ b/docs/data-sources/request.md @@ -32,6 +32,7 @@ TerraCurl request data source - `max_retry` (Number) Maximum number of tries until it is marked as failed - `request_body` (String) A request body to attach to the API call - `request_parameters` (Map of String) Map of parameters to attach to the API call +- `response_sensitive` (Boolean) Set to `true` to treat the response as sensitive. When enabled, the response body is written to `sensitive_response` (a sensitive attribute) and `response` is left empty so that secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior. - `retry_interval` (Number) Interval between each attempt - `skip_tls_verify` (Boolean) Set this to true to disable verification of the server's TLS certificate - `timeout` (Number) Time in seconds before each request times out. Defaults to 10 @@ -40,5 +41,6 @@ TerraCurl request data source - `id` (String) Example identifier - `request_url_string` (String) Request URL includes parameters if request specified -- `response` (String) JSON response received from request +- `response` (String) JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead. +- `sensitive_response` (String, Sensitive) JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`. - `status_code` (String) Response status code received from request diff --git a/docs/ephemeral-resources/request.md b/docs/ephemeral-resources/request.md index 929101b..b4585a5 100644 --- a/docs/ephemeral-resources/request.md +++ b/docs/ephemeral-resources/request.md @@ -61,6 +61,7 @@ TerraCurl request ephemeral resource - `renew_url` (String) Api endpoint to call - `request_body` (String) A request body to attach to the API call - `request_parameters` (Map of String) Map of parameters to attach to the API call +- `response_sensitive` (Boolean) Set to `true` to treat response bodies as sensitive. When enabled, response bodies are written to the corresponding `sensitive_*` attributes and the non-sensitive attributes are left empty so secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior. - `retry_interval` (Number) Interval between each attempt - `skip_close` (Boolean) Set to true if there are no api calls to make to clean up the ephemeral resource on the target platform. Default value is set to `true`. - `skip_renew` (Boolean) Set to true to skip renewing ephemeral resources. Default value is `true` @@ -70,10 +71,13 @@ TerraCurl request ephemeral resource ### Read-Only - `close_request_url_string` (String) Request URL includes parameters if request specified -- `close_response` (String) JSON response received from request +- `close_response` (String) JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_close_response` instead. - `id` (String) Example identifier - `renew_request_url_string` (String) Request URL includes parameters if request specified -- `renew_response` (String) JSON response received from request +- `renew_response` (String) JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_renew_response` instead. - `request_url_string` (String) Request URL includes parameters if request specified -- `response` (String) JSON response received from request +- `response` (String) JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead. +- `sensitive_close_response` (String, Sensitive) JSON response received from close request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`. +- `sensitive_renew_response` (String, Sensitive) JSON response received from renew request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`. +- `sensitive_response` (String, Sensitive) JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`. - `status_code` (String) Response status code received from request diff --git a/docs/resources/request.md b/docs/resources/request.md index 4af5502..0fc6815 100644 --- a/docs/resources/request.md +++ b/docs/resources/request.md @@ -58,6 +58,7 @@ TerraCurl request resource - `read_url` (String) API endpoint for reading resource state. Required if `skip_read` is false. - `request_body` (String) A request body to attach to the API call - `request_parameters` (Map of String) Map of parameters to attach to the API call +- `response_sensitive` (Boolean) Set to `true` to treat the response as sensitive. When enabled, the response body is written to `sensitive_response` (a sensitive attribute) and `response` is left empty so that secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior. - `retry_interval` (Number) Interval between each attempt - `skip_destroy` (Boolean) Set this to true to skip issuing a request when the resource is being destroyed - `skip_read` (Boolean) Set to true to skip the read operation (no drift detection). Defaults to true. @@ -70,5 +71,6 @@ TerraCurl request resource - `drift_marker` (String) Marker to track state drift and trigger resource replacement - `id` (String) Example identifier - `request_url_string` (String) Request URL includes parameters if request specified -- `response` (String) JSON response received from request +- `response` (String) JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead. +- `sensitive_response` (String, Sensitive) JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`. - `status_code` (String) Response status code received from request diff --git a/internal/provider/curl_data_source.go b/internal/provider/curl_data_source.go index 0fd6f3a..bab6199 100644 --- a/internal/provider/curl_data_source.go +++ b/internal/provider/curl_data_source.go @@ -47,6 +47,8 @@ type CurlDataSourceModel struct { MaxRetry types.Int64 `tfsdk:"max_retry"` Timeout types.Int64 `tfsdk:"timeout"` Response types.String `tfsdk:"response"` + SensitiveResponse types.String `tfsdk:"sensitive_response"` + ResponseSensitive types.Bool `tfsdk:"response_sensitive"` ResponseCodes types.List `tfsdk:"response_codes"` StatusCode types.String `tfsdk:"status_code"` } @@ -128,7 +130,17 @@ func (d *CurlDataSource) Schema(ctx context.Context, req datasource.SchemaReques }, "response": schema.StringAttribute{ Computed: true, - MarkdownDescription: "JSON response received from request", + MarkdownDescription: "JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead.", + }, + "sensitive_response": schema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`.", + }, + "response_sensitive": schema.BoolAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Set to `true` to treat the response as sensitive. When enabled, the response body is written to `sensitive_response` (a sensitive attribute) and `response` is left empty so that secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior.", }, "response_codes": schema.ListAttribute{ Required: true, @@ -286,7 +298,7 @@ func (d *CurlDataSource) Read(ctx context.Context, req datasource.ReadRequest, r } data.RequestUrlString = types.StringValue(request.URL.String()) - data.Response = types.StringValue(bodyString) + setDataSourceResponseValues(&data, bodyString) data.StatusCode = types.StringValue(strconv.Itoa(statusCode)) // Save data into Terraform state. diff --git a/internal/provider/curl_data_source_test.go b/internal/provider/curl_data_source_test.go index 595552f..4fdd17b 100644 --- a/internal/provider/curl_data_source_test.go +++ b/internal/provider/curl_data_source_test.go @@ -329,3 +329,89 @@ data "terracurl_request" "tls_skip_verify_test" { } `, url, certFile, keyFile) } + +func TestAccDataSourceCurlResponseSensitive(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + secretBody := `{"token": "super-secret-token", "key": "key-1234"}` + httpmock.RegisterResponder( + "GET", + "https://example.com/data-sensitive", + httpmock.NewStringResponder(200, secretBody), + ) + + rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceCurlResponseSensitive(rName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.terracurl_request.sensitive_test", "response_sensitive", "true"), + resource.TestCheckResourceAttr("data.terracurl_request.sensitive_test", "response", ""), + resource.TestCheckResourceAttr("data.terracurl_request.sensitive_test", "sensitive_response", secretBody), + ), + }, + }, + }) +} + +func testAccDataSourceCurlResponseSensitive(name string) string { + return fmt.Sprintf(` +data "terracurl_request" "sensitive_test" { + name = "%s" + url = "https://example.com/data-sensitive" + method = "GET" + response_codes = ["200"] + response_sensitive = true +} +`, name) +} + +func TestAccDataSourceCurlResponseSensitiveDefault(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + body := `{"message": "ok"}` + httpmock.RegisterResponder( + "GET", + "https://example.com/data-default", + httpmock.NewStringResponder(200, body), + ) + + rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceCurlResponseSensitiveDefault(rName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.terracurl_request.default_test", "response", body), + resource.TestCheckResourceAttr("data.terracurl_request.default_test", "sensitive_response", ""), + ), + }, + }, + }) +} + +func testAccDataSourceCurlResponseSensitiveDefault(name string) string { + return fmt.Sprintf(` +data "terracurl_request" "default_test" { + name = "%s" + url = "https://example.com/data-default" + method = "GET" + response_codes = ["200"] +} +`, name) +} diff --git a/internal/provider/curl_ephemeral_resource.go b/internal/provider/curl_ephemeral_resource.go index 2729353..25e681b 100644 --- a/internal/provider/curl_ephemeral_resource.go +++ b/internal/provider/curl_ephemeral_resource.go @@ -59,8 +59,10 @@ type CurlEphemeralModel struct { RetryInterval types.Int64 `tfsdk:"retry_interval"` MaxRetry types.Int64 `tfsdk:"max_retry"` Timeout types.Int64 `tfsdk:"timeout"` - Response types.String `tfsdk:"response"` - ResponseCodes types.List `tfsdk:"response_codes"` + Response types.String `tfsdk:"response"` + SensitiveResponse types.String `tfsdk:"sensitive_response"` + ResponseSensitive types.Bool `tfsdk:"response_sensitive"` + ResponseCodes types.List `tfsdk:"response_codes"` StatusCode types.String `tfsdk:"status_code"` SkipRenew types.Bool `tfsdk:"skip_renew"` @@ -80,6 +82,7 @@ type CurlEphemeralModel struct { RenewMaxRetry types.Int64 `tfsdk:"renew_max_retry"` RenewTimeout types.Int64 `tfsdk:"renew_timeout"` RenewResponse types.String `tfsdk:"renew_response"` + SensitiveRenewResponse types.String `tfsdk:"sensitive_renew_response"` RenewResponseCodes types.List `tfsdk:"renew_response_codes"` SkipClose types.Bool `tfsdk:"skip_close"` @@ -99,6 +102,7 @@ type CurlEphemeralModel struct { CloseMaxRetry types.Int64 `tfsdk:"close_max_retry"` CloseTimeout types.Int64 `tfsdk:"close_timeout"` CloseResponse types.String `tfsdk:"close_response"` + SensitiveCloseResponse types.String `tfsdk:"sensitive_close_response"` CloseResponseCodes types.List `tfsdk:"close_response_codes"` } @@ -177,7 +181,17 @@ func (e *EphemeralCurlResource) Schema(ctx context.Context, req ephemeral.Schema }, "response": schema.StringAttribute{ Computed: true, - MarkdownDescription: "JSON response received from request", + MarkdownDescription: "JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead.", + }, + "sensitive_response": schema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`.", + }, + "response_sensitive": schema.BoolAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Set to `true` to treat response bodies as sensitive. When enabled, response bodies are written to the corresponding `sensitive_*` attributes and the non-sensitive attributes are left empty so secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior.", }, "response_codes": schema.ListAttribute{ Required: true, @@ -260,7 +274,12 @@ func (e *EphemeralCurlResource) Schema(ctx context.Context, req ephemeral.Schema }, "renew_response": schema.StringAttribute{ Computed: true, - MarkdownDescription: "JSON response received from request", + MarkdownDescription: "JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_renew_response` instead.", + }, + "sensitive_renew_response": schema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "JSON response received from renew request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`.", }, "renew_response_codes": schema.ListAttribute{ Optional: true, @@ -334,7 +353,12 @@ func (e *EphemeralCurlResource) Schema(ctx context.Context, req ephemeral.Schema }, "close_response": schema.StringAttribute{ Computed: true, - MarkdownDescription: "JSON response received from request", + MarkdownDescription: "JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_close_response` instead.", + }, + "sensitive_close_response": schema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "JSON response received from close request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`.", }, "close_response_codes": schema.ListAttribute{ Optional: true, @@ -514,7 +538,7 @@ func (e *EphemeralCurlResource) Open(ctx context.Context, req ephemeral.OpenRequ } data.RequestUrlString = types.StringValue(request.URL.String()) - data.Response = types.StringValue(bodyString) + setEphemeralOpenResponse(&data, bodyString) data.StatusCode = types.StringValue(strconv.Itoa(statusCode)) tflog.Debug(ctx, fmt.Sprintf("renew parameters in Open() is set to %v", convertMap(data.RenewRequestParameters))) @@ -544,7 +568,8 @@ func (e *EphemeralCurlResource) Open(ctx context.Context, req ephemeral.OpenRequ "Timeout": data.Timeout.ValueInt64(), "StatusCode": data.StatusCode.ValueString(), "Response_codes": data.ResponseCodes.Elements(), - "Response": data.Response.ValueString(), + "Response": bodyString, + "ResponseSensitive": data.ResponseSensitive.ValueBool(), "SkipRenew": data.SkipRenew.ValueBool(), "RenewUrl": data.RenewUrl.ValueString(), @@ -984,7 +1009,13 @@ func (e *EphemeralCurlResource) Renew(ctx context.Context, req ephemeral.RenewRe return } + responseSensitive, ok := privateMap["ResponseSensitive"].(bool) + if !ok { + responseSensitive = false + } + privateData := CurlEphemeralModel{ + ResponseSensitive: types.BoolValue(responseSensitive), RenewMethod: types.StringValue(renewMethod), RenewUrl: types.StringValue(renewUrl), SkipRenew: types.BoolValue(skipRenew), @@ -1161,7 +1192,7 @@ func (e *EphemeralCurlResource) Renew(ctx context.Context, req ephemeral.RenewRe } privateData.RenewRequestUrlString = types.StringValue(request.URL.String()) - privateData.RenewResponse = types.StringValue(bodyString) + setEphemeralRenewResponse(&privateData, bodyString) privateData.StatusCode = types.StringValue(strconv.Itoa(statusCode)) // Renew again @@ -1362,7 +1393,13 @@ func (e *EphemeralCurlResource) Close(ctx context.Context, req ephemeral.CloseRe return } + responseSensitive, ok := privateMap["ResponseSensitive"].(bool) + if !ok { + responseSensitive = false + } + privateData := CurlEphemeralModel{ + ResponseSensitive: types.BoolValue(responseSensitive), CloseMethod: types.StringValue(closeMethod), CloseUrl: types.StringValue(closeUrl), SkipClose: types.BoolValue(skipClose), @@ -1521,6 +1558,7 @@ func (e *EphemeralCurlResource) Close(ctx context.Context, req ephemeral.CloseRe } } + setEphemeralCloseResponse(&privateData, string(bodyBytes)) } func (e EphemeralCurlResource) ConfigValidators(ctx context.Context) []ephemeral.ConfigValidator { diff --git a/internal/provider/curl_ephemeral_resource_test.go b/internal/provider/curl_ephemeral_resource_test.go index ee955a5..be035f0 100644 --- a/internal/provider/curl_ephemeral_resource_test.go +++ b/internal/provider/curl_ephemeral_resource_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/jarcoal/httpmock" "net/http" "os" @@ -850,3 +851,116 @@ func TestAccCurlEmphemeralResourceWithTLSSkipVerify(t *testing.T) { }, }) } + +const testAccEphemeralResourceSensitive = ` +ephemeral "terracurl_request" "ephems" { + method = "POST" + name = "test" + response_codes = ["201"] + url = "https://example.com/open" + response_sensitive = true + + skip_renew = false + renew_interval = "-10" + renew_url = "https://example.com/renew" + renew_response_codes = ["200"] + renew_method = "GET" + + skip_close = false + close_url = "https://example.com/close" + close_response_codes = ["204"] + close_method = "DELETE" +} + +provider "echo" { + data = ephemeral.terracurl_request.ephems +} + +resource "echo" "test" {} +` + +func TestAccEphemeralResourceResponseSensitive(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + skipIfTerraformIsLegacy(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder( + "POST", + "https://example.com/open", + httpmock.NewStringResponder(201, `token-123`), + ) + httpmock.RegisterResponder( + "GET", + "https://example.com/renew", + httpmock.NewStringResponder(200, `renew-token`), + ) + httpmock.RegisterResponder( + "DELETE", + "https://example.com/close", + httpmock.NewStringResponder(204, `close-token`), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho, + CheckDestroy: nil, + Steps: []resource.TestStep{ + { + Config: testAccEphemeralResourceSensitive, + Check: resource.ComposeTestCheckFunc( + testMockEndpointRegister("POST https://example.com/open"), + testMockEndpointRegister("GET https://example.com/renew"), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("response"), + knownvalue.StringExact(""), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("sensitive_response"), + knownvalue.StringExact("token-123"), + ), + }, + }, + { + RefreshState: true, + Check: resource.ComposeTestCheckFunc( + testMockEndpointRegister("DELETE https://example.com/close"), + ), + }, + }, + }) +} + +func TestEphemeralResponseSensitiveRouting(t *testing.T) { + data := CurlEphemeralModel{ + ResponseSensitive: types.BoolValue(true), + } + + setEphemeralOpenResponse(&data, "open-body") + setEphemeralRenewResponse(&data, "renew-body") + setEphemeralCloseResponse(&data, "close-body") + + if data.Response.ValueString() != "" { + t.Fatalf("expected empty response, got %q", data.Response.ValueString()) + } + if data.SensitiveResponse.ValueString() != "open-body" { + t.Fatalf("expected sensitive_response open-body, got %q", data.SensitiveResponse.ValueString()) + } + if data.RenewResponse.ValueString() != "" { + t.Fatalf("expected empty renew_response, got %q", data.RenewResponse.ValueString()) + } + if data.SensitiveRenewResponse.ValueString() != "renew-body" { + t.Fatalf("expected sensitive_renew_response renew-body, got %q", data.SensitiveRenewResponse.ValueString()) + } + if data.CloseResponse.ValueString() != "" { + t.Fatalf("expected empty close_response, got %q", data.CloseResponse.ValueString()) + } + if data.SensitiveCloseResponse.ValueString() != "close-body" { + t.Fatalf("expected sensitive_close_response close-body, got %q", data.SensitiveCloseResponse.ValueString()) + } +} diff --git a/internal/provider/curl_resource.go b/internal/provider/curl_resource.go index 5259083..c78c975 100644 --- a/internal/provider/curl_resource.go +++ b/internal/provider/curl_resource.go @@ -63,6 +63,8 @@ type CurlResourceModel struct { MaxRetry types.Int64 `tfsdk:"max_retry"` Timeout types.Int64 `tfsdk:"timeout"` Response types.String `tfsdk:"response"` + SensitiveResponse types.String `tfsdk:"sensitive_response"` + ResponseSensitive types.Bool `tfsdk:"response_sensitive"` ResponseCodes types.List `tfsdk:"response_codes"` StatusCode types.String `tfsdk:"status_code"` SkipDestroy types.Bool `tfsdk:"skip_destroy"` @@ -212,7 +214,18 @@ func (r *CurlResource) Schema(ctx context.Context, req resource.SchemaRequest, r }, "response": schema.StringAttribute{ Computed: true, - MarkdownDescription: "JSON response received from request", + MarkdownDescription: "JSON response received from request. Empty when `response_sensitive` is `true`; use `sensitive_response` instead.", + }, + "sensitive_response": schema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "JSON response received from request, marked as sensitive so it is not displayed in plan output. Populated only when `response_sensitive` is `true`.", + }, + "response_sensitive": schema.BoolAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Set to `true` to treat the response as sensitive. When enabled, the response body is written to `sensitive_response` (a sensitive attribute) and `response` is left empty so that secret values are not displayed in plan output. Defaults to `false` to preserve existing behavior.", + Default: booldefault.StaticBool(false), }, "response_codes": schema.ListAttribute{ Required: true, @@ -600,7 +613,7 @@ func (r *CurlResource) Create(ctx context.Context, req resource.CreateRequest, r data.DriftMarker = types.StringValue("initial") data.DestroyRequestUrlString = types.StringValue(data.DestroyUrl.ValueString()) data.RequestUrlString = types.StringValue(request.URL.String()) - data.Response = types.StringValue(sanitizedResponse) + setResourceResponseValues(&data, sanitizedResponse) data.StatusCode = types.StringValue(strconv.Itoa(statusCode)) diags := resp.State.Set(ctx, &data) resp.Diagnostics.Append(diags...) @@ -716,8 +729,10 @@ func (r *CurlResource) Read(ctx context.Context, req resource.ReadRequest, resp return } - // Compare old and new sanitized responses - oldSanitized, err := sanitizeResponse(data.Response.ValueString(), ignoredFields) + // Compare old and new sanitized responses. The prior response is stored in + // whichever attribute matches the current response_sensitive setting. + priorResponse := priorResponseValue(data.ResponseSensitive, data.Response, data.SensitiveResponse) + oldSanitized, err := sanitizeResponse(priorResponse, ignoredFields) if err != nil { resp.Diagnostics.AddError("Sanitize Error", fmt.Sprintf("Failed to sanitize prior response: %s", err)) return @@ -734,8 +749,8 @@ func (r *CurlResource) Read(ctx context.Context, req resource.ReadRequest, resp } } - // Store the new sanitized response - data.Response = types.StringValue(sanitizedResponse) + // Store the new sanitized response in the appropriate attribute. + setResourceResponseValues(&data, sanitizedResponse) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -894,7 +909,7 @@ func (r *CurlResource) Delete(ctx context.Context, req resource.DeleteRequest, r data.DestroyRequestUrlString = types.StringValue(data.DestroyUrl.ValueString()) data.RequestUrlString = types.StringValue(request.URL.String()) - data.Response = types.StringValue(string(bodyBytes)) + setResourceResponseValues(&data, string(bodyBytes)) data.StatusCode = types.StringValue(strconv.Itoa(statusCode)) // Remove Resource from State @@ -1047,6 +1062,16 @@ func (r *CurlResource) UpgradeState(ctx context.Context) map[int64]resource.Stat resp.Diagnostics = diag.Diagnostics{} } + // New fields introduced after v0: default response_sensitive + // to false and leave sensitive_response empty. This preserves + // the prior non-sensitive response behavior on upgrade. + if oldState.ResponseSensitive.IsNull() || oldState.ResponseSensitive.IsUnknown() { + oldState.ResponseSensitive = types.BoolValue(false) + } + if oldState.SensitiveResponse.IsNull() || oldState.SensitiveResponse.IsUnknown() { + oldState.SensitiveResponse = types.StringValue("") + } + // The key change in v1: set skip_read to true and clear read-related fields oldState.SkipRead = types.BoolValue(true) oldState.ReadUrl = types.StringNull() diff --git a/internal/provider/curl_resource_test.go b/internal/provider/curl_resource_test.go index 640689d..1600de3 100644 --- a/internal/provider/curl_resource_test.go +++ b/internal/provider/curl_resource_test.go @@ -852,6 +852,98 @@ resource "terracurl_request" "ignore_test" { ` } +func TestAccresourceCurlResponseSensitive(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + secretBody := `{"token": "super-secret-token", "key": "key-1234"}` + expectedResponse := `{"key":"key-1234","token":"super-secret-token"}` + httpmock.RegisterResponder( + "POST", + "https://example.com/keys", + httpmock.NewStringResponder(200, secretBody), + ) + + rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccresourceCurlResponseSensitive(rName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("terracurl_request.sensitive", "response_sensitive", "true"), + resource.TestCheckResourceAttr("terracurl_request.sensitive", "response", ""), + resource.TestCheckResourceAttr("terracurl_request.sensitive", "sensitive_response", expectedResponse), + resource.TestCheckResourceAttr("terracurl_request.sensitive", "status_code", "200"), + ), + }, + }, + }) +} + +func testAccresourceCurlResponseSensitive(name string) string { + return fmt.Sprintf(` +resource "terracurl_request" "sensitive" { + name = "%s" + url = "https://example.com/keys" + method = "POST" + response_codes = ["200"] + response_sensitive = true + skip_destroy = true + skip_read = true +}`, name) +} + +func TestAccresourceCurlResponseSensitiveDefault(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + body := `{"message": "ok"}` + expectedResponse := `{"message":"ok"}` + httpmock.RegisterResponder( + "GET", + "https://example.com/default", + httpmock.NewStringResponder(200, body), + ) + + rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccresourceCurlResponseSensitiveDefault(rName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("terracurl_request.default", "response_sensitive", "false"), + resource.TestCheckResourceAttr("terracurl_request.default", "response", expectedResponse), + resource.TestCheckResourceAttr("terracurl_request.default", "sensitive_response", ""), + ), + }, + }, + }) +} + +func testAccresourceCurlResponseSensitiveDefault(name string) string { + return fmt.Sprintf(` +resource "terracurl_request" "default" { + name = "%s" + url = "https://example.com/default" + method = "GET" + response_codes = ["200"] + skip_destroy = true + skip_read = true +}`, name) +} + func testMockEndpointCount(endpoint string, expected int) resource.TestCheckFunc { return func(s *terraform.State) error { usage := httpmock.GetCallCountInfo() @@ -901,6 +993,8 @@ func TestCurlResource_StateUpgrade(t *testing.T) { "response_codes": schema.ListAttribute{ElementType: types.StringType, Optional: true}, "status_code": schema.StringAttribute{Computed: true}, "response": schema.StringAttribute{Computed: true}, + "sensitive_response": schema.StringAttribute{Computed: true, Sensitive: true}, + "response_sensitive": schema.BoolAttribute{Optional: true, Computed: true}, "request_url_string": schema.StringAttribute{Computed: true}, "max_retry": schema.Int64Attribute{Optional: true}, "retry_interval": schema.Int64Attribute{Optional: true}, @@ -1205,6 +1299,165 @@ func TestCurlResource_StateUpgrade_WithDestroyParameters(t *testing.T) { } } +// TestCurlResource_Read_ResponseSensitiveToggle tests that drift detection correctly +// handles the case where response_sensitive changes between operations. +func TestCurlResource_Read_ResponseSensitiveToggle(t *testing.T) { + t.Setenv("TF_ACC", "true") + t.Setenv("USE_DEFAULT_CLIENT_FOR_TESTS", "true") + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + initialResponse := `{"key":"value1"}` + changedResponse := `{"key":"value2"}` + + callCount := 0 + httpmock.RegisterResponder( + "GET", + "https://example.com/read", + func(req *http.Request) (*http.Response, error) { + callCount++ + if callCount == 1 { + return httpmock.NewStringResponse(200, initialResponse), nil + } + return httpmock.NewStringResponse(200, changedResponse), nil + }, + ) + + ctx := context.Background() + r := &CurlResource{} + + schemaResp := &resource2.SchemaResponse{} + r.Schema(ctx, resource2.SchemaRequest{}, schemaResp) + + initialState := CurlResourceModel{ + Id: types.StringValue("test"), + Name: types.StringValue("test"), + Url: types.StringValue("https://example.com/create"), + Method: types.StringValue("POST"), + SkipRead: types.BoolValue(false), + ReadUrl: types.StringValue("https://example.com/read"), + ReadMethod: types.StringValue("GET"), + ReadResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + ResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + ResponseSensitive: types.BoolValue(false), + Response: types.StringValue(initialResponse), + SensitiveResponse: types.StringValue(""), + DriftMarker: types.StringValue("initial"), + IgnoreResponseFields: types.ListNull(types.StringType), + DestroyResponseCodes: types.ListNull(types.StringType), + SkipDestroy: types.BoolValue(true), + Headers: types.MapNull(types.StringType), + RequestParameters: types.MapNull(types.StringType), + ReadHeaders: types.MapNull(types.StringType), + ReadParameters: types.MapNull(types.StringType), + DestroyHeaders: types.MapNull(types.StringType), + DestroyRequestParameters: types.MapNull(types.StringType), + } + + state1 := tfsdk.State{Schema: schemaResp.Schema} + diags := state1.Set(ctx, &initialState) + if diags.HasError() { + t.Fatalf("Failed to set initial state: %v", diags) + } + + readReq1 := resource2.ReadRequest{State: state1} + readResp1 := &resource2.ReadResponse{State: state1} + r.Read(ctx, readReq1, readResp1) + + if readResp1.Diagnostics.HasError() { + t.Fatalf("Read failed: %v", readResp1.Diagnostics) + } + + var stateAfterRead1 CurlResourceModel + diags = readResp1.State.Get(ctx, &stateAfterRead1) + if diags.HasError() { + t.Fatalf("Failed to get state after first read: %v", diags) + } + + if stateAfterRead1.DriftMarker.ValueString() != "initial" { + t.Errorf("Expected no drift after first read, but drift_marker changed to: %s", stateAfterRead1.DriftMarker.ValueString()) + } + + stateWithToggle := initialState + stateWithToggle.DriftMarker = types.StringValue("initial") + + state2 := tfsdk.State{Schema: schemaResp.Schema} + diags = state2.Set(ctx, &stateWithToggle) + if diags.HasError() { + t.Fatalf("Failed to set state with toggle: %v", diags) + } + + readReq2 := resource2.ReadRequest{State: state2} + readResp2 := &resource2.ReadResponse{State: state2} + r.Read(ctx, readReq2, readResp2) + + if readResp2.Diagnostics.HasError() { + t.Fatalf("Read failed after toggle: %v", readResp2.Diagnostics) + } + + var stateAfterRead2 CurlResourceModel + diags = readResp2.State.Get(ctx, &stateAfterRead2) + if diags.HasError() { + t.Fatalf("Failed to get state after second read: %v", diags) + } + + if stateAfterRead2.DriftMarker.ValueString() == "initial" { + t.Error("Expected drift to be detected after response changed, but drift_marker remained 'initial'") + } + + stateWithReverseToggle := CurlResourceModel{ + Id: types.StringValue("test"), + Name: types.StringValue("test"), + Url: types.StringValue("https://example.com/create"), + Method: types.StringValue("POST"), + SkipRead: types.BoolValue(false), + ReadUrl: types.StringValue("https://example.com/read"), + ReadMethod: types.StringValue("GET"), + ReadResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + ResponseCodes: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("200")}), + ResponseSensitive: types.BoolValue(true), + Response: types.StringValue(""), + SensitiveResponse: types.StringValue(changedResponse), + DriftMarker: types.StringValue("initial"), + IgnoreResponseFields: types.ListNull(types.StringType), + DestroyResponseCodes: types.ListNull(types.StringType), + SkipDestroy: types.BoolValue(true), + Headers: types.MapNull(types.StringType), + RequestParameters: types.MapNull(types.StringType), + ReadHeaders: types.MapNull(types.StringType), + ReadParameters: types.MapNull(types.StringType), + DestroyHeaders: types.MapNull(types.StringType), + DestroyRequestParameters: types.MapNull(types.StringType), + } + + state3 := tfsdk.State{Schema: schemaResp.Schema} + diags = state3.Set(ctx, &stateWithReverseToggle) + if diags.HasError() { + t.Fatalf("Failed to set state with reverse toggle: %v", diags) + } + + callCount = 0 + + readReq3 := resource2.ReadRequest{State: state3} + readResp3 := &resource2.ReadResponse{State: state3} + r.Read(ctx, readReq3, readResp3) + + if readResp3.Diagnostics.HasError() { + t.Fatalf("Read failed after reverse toggle: %v", readResp3.Diagnostics) + } + + var stateAfterRead3 CurlResourceModel + diags = readResp3.State.Get(ctx, &stateAfterRead3) + if diags.HasError() { + t.Fatalf("Failed to get state after third read: %v", diags) + } + + if stateAfterRead3.DriftMarker.ValueString() == "initial" { + t.Error("Expected drift to be detected after reverse toggle, but drift_marker remained 'initial'") + } +} + // TestCurlResource_StateUpgrade_EmptyDestroyParameters tests handling of null destroy_parameters. func TestCurlResource_StateUpgrade_EmptyDestroyParameters(t *testing.T) { ctx := context.Background() diff --git a/internal/provider/utilities.go b/internal/provider/utilities.go index d0e359c..a527ff0 100644 --- a/internal/provider/utilities.go +++ b/internal/provider/utilities.go @@ -43,6 +43,78 @@ func sanitizeResponse(response string, fieldsToIgnore []string) (string, error) return string(filteredBytes), nil } +// setResponseValue writes body to either response or sensitiveResponse based on +// the sensitive flag. The unused attribute is always set to an empty string so +// Terraform does not report it as unknown. +func setResponseValue(sensitive bool, response, sensitiveResponse *types.String, body string) { + if sensitive { + *response = types.StringValue("") + *sensitiveResponse = types.StringValue(body) + } else { + *response = types.StringValue(body) + *sensitiveResponse = types.StringValue("") + } +} + +func responseSensitiveEnabled(value types.Bool) bool { + if value.IsNull() || value.IsUnknown() { + return false + } + return value.ValueBool() +} + +func priorResponseValue(responseSensitive types.Bool, response, sensitiveResponse types.String) string { + if responseSensitiveEnabled(responseSensitive) { + return sensitiveResponse.ValueString() + } + return response.ValueString() +} + +func setResourceResponseValues(data *CurlResourceModel, body string) { + setResponseValue( + responseSensitiveEnabled(data.ResponseSensitive), + &data.Response, + &data.SensitiveResponse, + body, + ) +} + +func setEphemeralOpenResponse(data *CurlEphemeralModel, body string) { + setResponseValue( + responseSensitiveEnabled(data.ResponseSensitive), + &data.Response, + &data.SensitiveResponse, + body, + ) +} + +func setEphemeralRenewResponse(data *CurlEphemeralModel, body string) { + setResponseValue( + responseSensitiveEnabled(data.ResponseSensitive), + &data.RenewResponse, + &data.SensitiveRenewResponse, + body, + ) +} + +func setEphemeralCloseResponse(data *CurlEphemeralModel, body string) { + setResponseValue( + responseSensitiveEnabled(data.ResponseSensitive), + &data.CloseResponse, + &data.SensitiveCloseResponse, + body, + ) +} + +func setDataSourceResponseValues(data *CurlDataSourceModel, body string) { + setResponseValue( + responseSensitiveEnabled(data.ResponseSensitive), + &data.Response, + &data.SensitiveResponse, + body, + ) +} + func responseCodeChecker(s []string, str string) bool { for _, v := range s { if v == str { From 3734fa23e3436fa7ddc096f458a45904e74fa455 Mon Sep 17 00:00:00 2001 From: devops-rob Date: Mon, 20 Jul 2026 17:49:12 +0100 Subject: [PATCH 2/2] Fix gofmt formatting in ephemeral resource files. Co-authored-by: Cursor --- internal/provider/curl_ephemeral_resource.go | 8 ++++---- internal/provider/curl_ephemeral_resource_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/provider/curl_ephemeral_resource.go b/internal/provider/curl_ephemeral_resource.go index 25e681b..38a94db 100644 --- a/internal/provider/curl_ephemeral_resource.go +++ b/internal/provider/curl_ephemeral_resource.go @@ -59,10 +59,10 @@ type CurlEphemeralModel struct { RetryInterval types.Int64 `tfsdk:"retry_interval"` MaxRetry types.Int64 `tfsdk:"max_retry"` Timeout types.Int64 `tfsdk:"timeout"` - Response types.String `tfsdk:"response"` - SensitiveResponse types.String `tfsdk:"sensitive_response"` - ResponseSensitive types.Bool `tfsdk:"response_sensitive"` - ResponseCodes types.List `tfsdk:"response_codes"` + Response types.String `tfsdk:"response"` + SensitiveResponse types.String `tfsdk:"sensitive_response"` + ResponseSensitive types.Bool `tfsdk:"response_sensitive"` + ResponseCodes types.List `tfsdk:"response_codes"` StatusCode types.String `tfsdk:"status_code"` SkipRenew types.Bool `tfsdk:"skip_renew"` diff --git a/internal/provider/curl_ephemeral_resource_test.go b/internal/provider/curl_ephemeral_resource_test.go index be035f0..7139008 100644 --- a/internal/provider/curl_ephemeral_resource_test.go +++ b/internal/provider/curl_ephemeral_resource_test.go @@ -2,12 +2,12 @@ package provider import ( "fmt" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/jarcoal/httpmock" "net/http" "os"