From d2d3ea416bec02eeb01fe8967123e3bd676de7f8 Mon Sep 17 00:00:00 2001 From: Andrew Kaplan Date: Tue, 14 Oct 2025 12:57:12 -0700 Subject: [PATCH 1/2] Add sensitive body support to azapi_data_plane_resource, add Acc tests to match --- .../services/azapi_data_plane_resource.go | 146 ++++++++------ .../azapi_data_plane_resource_test.go | 182 ++++++++++++++++++ 2 files changed, 269 insertions(+), 59 deletions(-) diff --git a/internal/services/azapi_data_plane_resource.go b/internal/services/azapi_data_plane_resource.go index 2f962ee7e..705c2cb87 100644 --- a/internal/services/azapi_data_plane_resource.go +++ b/internal/services/azapi_data_plane_resource.go @@ -45,6 +45,8 @@ type DataPlaneResourceModel struct { ParentID types.String `tfsdk:"parent_id"` Type types.String `tfsdk:"type"` Body types.Dynamic `tfsdk:"body"` + SensitiveBody types.Dynamic `tfsdk:"sensitive_body"` + SensitiveBodyVersion types.Map `tfsdk:"sensitive_body_version"` IgnoreCasing types.Bool `tfsdk:"ignore_casing"` IgnoreMissingProperty types.Bool `tfsdk:"ignore_missing_property"` ReplaceTriggersExternalValues types.Dynamic `tfsdk:"replace_triggers_external_values"` @@ -102,7 +104,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: docstrings.ID(), }, - "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ @@ -110,7 +111,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: "Specifies the name of the Azure resource. Changing this forces a new resource to be created.", }, - "parent_id": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ @@ -121,7 +121,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: "The ID of the azure resource in which this resource is created. Changing this forces a new resource to be created.", }, - "type": schema.StringAttribute{ Required: true, Validators: []validator.String{ @@ -129,7 +128,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: docstrings.Type(), }, - // The body attribute is a dynamic attribute that only allows users to specify the resource body as an HCL object "body": schema.DynamicAttribute{ Optional: true, @@ -144,21 +142,28 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR myvalidator.DynamicIsNotStringValidator(), }, }, - + "sensitive_body": schema.DynamicAttribute{ + Optional: true, + WriteOnly: true, + MarkdownDescription: docstrings.SensitiveBody(), + }, + "sensitive_body_version": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + MarkdownDescription: docstrings.SensitiveBodyVersion(), + }, "ignore_casing": schema.BoolAttribute{ Optional: true, Computed: true, Default: defaults.BoolDefault(false), MarkdownDescription: docstrings.Body(), }, - "ignore_missing_property": schema.BoolAttribute{ Optional: true, Computed: true, Default: defaults.BoolDefault(true), MarkdownDescription: docstrings.IgnoreMissingProperty(), }, - "response_export_values": schema.DynamicAttribute{ Optional: true, PlanModifiers: []planmodifier.Dynamic{ @@ -166,9 +171,7 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: docstrings.ResponseExportValues(), }, - "retry": retry.RetrySchema(ctx), - "replace_triggers_external_values": schema.DynamicAttribute{ Optional: true, MarkdownDescription: "Will trigger a replace of the resource when the value changes and is not `null`. This can be used by practitioners to force a replace of the resource when certain values change, e.g. changing the SKU of a virtual machine based on the value of variables or locals. " + @@ -198,13 +201,11 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR planmodifierdynamic.RequiresReplaceIfNotNull(), }, }, - "replace_triggers_refs": schema.ListAttribute{ ElementType: types.StringType, Optional: true, MarkdownDescription: "A list of paths in the current Terraform configuration. When the values at these paths change, the resource will be replaced.", }, - "locks": schema.ListAttribute{ ElementType: types.StringType, Optional: true, @@ -213,18 +214,15 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR }, MarkdownDescription: docstrings.Locks(), }, - "output": schema.DynamicAttribute{ Computed: true, MarkdownDescription: docstrings.Output("azapi_data_plane_resource"), }, - "create_headers": schema.MapAttribute{ ElementType: types.StringType, Optional: true, MarkdownDescription: "A mapping of headers to be sent with the create request.", }, - "create_query_parameters": schema.MapAttribute{ ElementType: types.ListType{ ElemType: types.StringType, @@ -232,13 +230,11 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR Optional: true, MarkdownDescription: "A mapping of query parameters to be sent with the create request.", }, - "update_headers": schema.MapAttribute{ ElementType: types.StringType, Optional: true, MarkdownDescription: "A mapping of headers to be sent with the update request.", }, - "update_query_parameters": schema.MapAttribute{ ElementType: types.ListType{ ElemType: types.StringType, @@ -246,13 +242,11 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR Optional: true, MarkdownDescription: "A mapping of query parameters to be sent with the update request.", }, - "delete_headers": schema.MapAttribute{ ElementType: types.StringType, Optional: true, MarkdownDescription: "A mapping of headers to be sent with the delete request.", }, - "delete_query_parameters": schema.MapAttribute{ ElementType: types.ListType{ ElemType: types.StringType, @@ -260,13 +254,11 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR Optional: true, MarkdownDescription: "A mapping of query parameters to be sent with the delete request.", }, - "read_headers": schema.MapAttribute{ ElementType: types.StringType, Optional: true, MarkdownDescription: "A mapping of headers to be sent with the read request.", }, - "read_query_parameters": schema.MapAttribute{ ElementType: types.ListType{ ElemType: types.StringType, @@ -275,7 +267,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR MarkdownDescription: "A mapping of query parameters to be sent with the read request.", }, }, - Blocks: map[string]schema.Block{ "timeouts": timeouts.Block(ctx, timeouts.Opts{ Create: true, @@ -284,7 +275,6 @@ func (r *DataPlaneResource) Schema(ctx context.Context, request resource.SchemaR Delete: true, }), }, - Version: 2, } } @@ -297,27 +287,45 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod if response.Diagnostics.HasError() { return } - // destroy doesn't need to modify plan if config == nil { return } + if diags := validateDataPlaneDefinitions(config, config.Body, "body"); diags.HasError() { + response.Diagnostics.Append(diags...) + return + } + if diags := validateDataPlaneDefinitions(config, config.SensitiveBody, "sensitive_body"); diags.HasError() { + response.Diagnostics.Append(diags...) + return + } + if state == nil || !plan.ResponseExportValues.Equal(state.ResponseExportValues) || !dynamic.SemanticallyEqual(plan.Body, state.Body) { plan.Output = basetypes.NewDynamicUnknown() } else { plan.Output = state.Output } - response.Diagnostics.Append(response.Plan.Set(ctx, plan)...) + if state != nil { + // Set output as unknown to trigger a plan diff, if ephemral body has changed + diff, diags := ephemeralBodyChangeInPlan(ctx, request.Private, config.SensitiveBody, config.SensitiveBodyVersion, state.SensitiveBodyVersion) + if response.Diagnostics = append(response.Diagnostics, diags...); response.Diagnostics.HasError() { + return + } + if diff { + tflog.Info(ctx, `"sensitive_body" has changed`) + plan.Output = types.DynamicUnknown() + } + } + response.Diagnostics.Append(response.Plan.Set(ctx, plan)...) // Check if any paths in replace_triggers_refs have changed if state != nil && plan != nil && !plan.ReplaceTriggersRefs.IsNull() { refPaths := make(map[string]string) for pathIndex, refPath := range common.AsStringList(plan.ReplaceTriggersRefs) { refPaths[fmt.Sprintf("%d", pathIndex)] = refPath } - // read previous values from state stateData, err := dynamic.ToJSON(state.Body) if err != nil { @@ -331,7 +339,6 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod return } previousValues := flattenOutputJMES(stateModel, refPaths) - // read current values from plan planData, err := dynamic.ToJSON(plan.Body) if err != nil { @@ -345,7 +352,6 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod return } currentValues := flattenOutputJMES(planModel, refPaths) - // compare previous and current values if !reflect.DeepEqual(previousValues, currentValues) { response.RequiresReplace.Append(path.Root("body")) @@ -354,7 +360,7 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod } func (r *DataPlaneResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { - r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics) + r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics, response.Private) } func (r *DataPlaneResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { @@ -369,27 +375,24 @@ func (r *DataPlaneResource) Update(ctx context.Context, request resource.UpdateR if skip.CanSkipExternalRequest(state, plan, "update") { tflog.Debug(ctx, "azapi_resource.CreateUpdate skipping external request as no unskippable changes were detected") response.Diagnostics.Append(response.State.Set(ctx, plan)...) + return } tflog.Debug(ctx, "azapi_resource.CreateUpdate proceeding with external request as no skippable changes were detected") - r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics) + r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics, response.Private) } -func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State, diagnostics *diag.Diagnostics) { +func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State, diagnostics *diag.Diagnostics, privateData PrivateData) { var model DataPlaneResourceModel if diagnostics.Append(plan.Get(ctx, &model)...); diagnostics.HasError() { return } - id, err := parse.NewDataPlaneResourceId(model.Name.ValueString(), model.ParentID.ValueString(), model.Type.ValueString()) if err != nil { diagnostics.AddError("Invalid configuration", err.Error()) return } - ctx = tflog.SetField(ctx, "resource_id", id.ID()) - isNewResource := state == nil || state.Raw.IsNull() - var timeout time.Duration var diags diag.Diagnostics if isNewResource { @@ -403,12 +406,9 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s return } } - ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - client := r.ProviderData.DataPlaneClient - if isNewResource { // check if the resource already exists using the non-retry client to avoid issue where user specifies // a FooResourceNotFound error as a retryable error @@ -422,30 +422,53 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s return } } - body := make(map[string]interface{}) if err := unmarshalBody(model.Body, &body); err != nil { diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: %s`, err.Error())) return } + + var oldModel *DataPlaneResourceModel + if !isNewResource { + oldModel = &DataPlaneResourceModel{} + if diagnostics.Append(state.Get(ctx, oldModel)...); diagnostics.HasError() { + return + } + } + + sensitiveBodyVersionInState := types.MapNull(types.StringType) + if oldModel != nil { + sensitiveBodyVersionInState = oldModel.SensitiveBodyVersion + } + + sensitiveBody, err := unmarshalSensitiveBody(model.SensitiveBody, model.SensitiveBodyVersion, sensitiveBodyVersionInState) + if err != nil { + diagnostics.AddError("Invalid sensitive_body", fmt.Sprintf(`The argument "sensitive_body" is invalid: %s`, err.Error())) + return + } + + body = utils.MergeObject(body, sensitiveBody).(map[string]interface{}) + lockIds := common.AsStringList(model.Locks) slices.Sort(lockIds) for _, lockId := range lockIds { locks.ByID(lockId) defer locks.UnlockByID(lockId) } - requestOptions := clients.RequestOptions{ Headers: common.AsMapOfString(model.CreateHeaders), QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(model.CreateQueryParameters)), RetryOptions: clients.NewRetryOptions(model.Retry), } + if !isNewResource { + requestOptions.Headers = common.AsMapOfString(model.UpdateHeaders) + requestOptions.QueryParameters = clients.NewQueryParameters(common.AsMapOfLists(model.UpdateQueryParameters)) + } _, err = client.CreateOrUpdateThenPoll(ctx, id, body, requestOptions) if err != nil { diagnostics.AddError("Failed to create/update resource", fmt.Errorf("creating/updating %q: %+v", id, err).Error()) return } - requestOptions = clients.RequestOptions{ Headers: common.AsMapOfString(model.ReadHeaders), QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(model.ReadQueryParameters)), @@ -466,9 +489,7 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error()) return } - model.ID = basetypes.NewStringValue(id.ID()) - output, err := buildOutputFromBody(responseBody, model.ResponseExportValues, nil) if err != nil { diagnostics.AddError("Failed to build output", err.Error()) @@ -476,6 +497,17 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s } model.Output = output + if model.SensitiveBodyVersion.IsNull() { + writeOnlyBytes, err := dynamic.ToJSON(model.SensitiveBody) + if err != nil { + diagnostics.AddError("Invalid sensitive_body", err.Error()) + return + } + diagnostics.Append(ephemeralBodyPrivateMgr.Set(ctx, privateData, writeOnlyBytes)...) + } else { + diagnostics.Append(ephemeralBodyPrivateMgr.Set(ctx, privateData, nil)...) + } + diagnostics.Append(state.Set(ctx, model)...) } @@ -484,23 +516,19 @@ func (r *DataPlaneResource) Read(ctx context.Context, request resource.ReadReque if response.Diagnostics.Append(request.State.Get(ctx, &model)...); response.Diagnostics.HasError() { return } - readTimeout, diags := model.Timeouts.Read(ctx, 5*time.Minute) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return } - ctx, cancel := context.WithTimeout(ctx, readTimeout) defer cancel() - id, err := parse.DataPlaneResourceIDWithResourceType(model.ID.ValueString(), model.Type.ValueString()) if err != nil { response.Diagnostics.AddError("Error parsing ID", err.Error()) return } ctx = tflog.SetField(ctx, "resource_id", id.ID()) - client := r.ProviderData.DataPlaneClient requestOptions := clients.RequestOptions{ Headers: common.AsMapOfString(model.ReadHeaders), @@ -517,32 +545,27 @@ func (r *DataPlaneResource) Read(ctx context.Context, request resource.ReadReque response.Diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error()) return } - requestBody := make(map[string]interface{}) if err := unmarshalBody(model.Body, &requestBody); err != nil { response.Diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: %s`, err.Error())) return } - option := utils.UpdateJsonOption{ IgnoreCasing: model.IgnoreCasing.ValueBool(), IgnoreMissingProperty: model.IgnoreMissingProperty.ValueBool(), } body := utils.UpdateObject(requestBody, responseBody, option) - data, err := json.Marshal(body) if err != nil { response.Diagnostics.AddError("Invalid body", err.Error()) return } - output, err := buildOutputFromBody(responseBody, model.ResponseExportValues, nil) if err != nil { response.Diagnostics.AddError("Failed to build output", err.Error()) return } model.Output = output - if !model.Body.IsNull() { payload, err := dynamic.FromJSON(data, model.Body.UnderlyingValue().Type(ctx)) if err != nil { @@ -555,11 +578,9 @@ func (r *DataPlaneResource) Read(ctx context.Context, request resource.ReadReque } model.Body = payload } - model.Name = basetypes.NewStringValue(id.Name) model.ParentID = basetypes.NewStringValue(id.ParentId) model.Type = basetypes.NewStringValue(fmt.Sprintf("%s@%s", id.AzureResourceType, id.ApiVersion)) - response.Diagnostics.Append(response.State.Set(ctx, model)...) } @@ -569,7 +590,6 @@ func (r *DataPlaneResource) Delete(ctx context.Context, request resource.DeleteR if response.Diagnostics.HasError() { return } - deleteTimeout, diags := model.Timeouts.Delete(ctx, 30*time.Minute) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -577,24 +597,19 @@ func (r *DataPlaneResource) Delete(ctx context.Context, request resource.DeleteR } ctx, cancel := context.WithTimeout(ctx, deleteTimeout) defer cancel() - id, err := parse.DataPlaneResourceIDWithResourceType(model.ID.ValueString(), model.Type.ValueString()) if err != nil { response.Diagnostics.AddError("Error parsing ID", err.Error()) return } - ctx = tflog.SetField(ctx, "resource_id", id.ID()) - client := r.ProviderData.DataPlaneClient - lockIds := common.AsStringList(model.Locks) slices.Sort(lockIds) for _, lockId := range lockIds { locks.ByID(lockId) defer locks.UnlockByID(lockId) } - requestOptions := clients.RequestOptions{ Headers: common.AsMapOfString(model.DeleteHeaders), QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(model.DeleteQueryParameters)), @@ -605,3 +620,16 @@ func (r *DataPlaneResource) Delete(ctx context.Context, request resource.DeleteR response.Diagnostics.AddError("Failed to delete resource", fmt.Errorf("deleting %s: %+v", id, err).Error()) } } + +func validateDataPlaneDefinitions(model *DataPlaneResourceModel, body types.Dynamic, attributePath string) diag.Diagnostics { + diags := diag.Diagnostics{} + if body.IsNull() || body.IsUnknown() || body.IsUnderlyingValueNull() || body.IsUnderlyingValueUnknown() { + return diags + } + // DataPlaneResourceModel doesn't have tags, location, or identity fields + // so we only need to check for interface compatibility + if _, ok := body.UnderlyingValue().(types.Object); !ok { + diags.AddError("Invalid configuration", fmt.Sprintf(`The argument "%s" must be an object`, attributePath)) + } + return diags +} diff --git a/internal/services/azapi_data_plane_resource_test.go b/internal/services/azapi_data_plane_resource_test.go index cd854b3e4..c65d5fd40 100644 --- a/internal/services/azapi_data_plane_resource_test.go +++ b/internal/services/azapi_data_plane_resource_test.go @@ -149,6 +149,188 @@ func TestAccDataPlaneResource_replaceTriggeredByExternalValues(t *testing.T) { }) } +func TestAccDataPlaneResource_sensitiveBody(t *testing.T) { + data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") + r := DataPlaneResource{} + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.sensitiveBody(data), + ExternalProviders: externalProvidersAzurerm(), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + }) +} + +func TestAccDataPlaneResource_sensitiveBodyVersion(t *testing.T) { + data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") + r := DataPlaneResource{} + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.sensitiveBodyWithVersion(data), + ExternalProviders: externalProvidersAzurerm(), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + { + Config: r.sensitiveBodyWithVersionUpdated(data), + ExternalProviders: externalProvidersAzurerm(), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + }) +} + +func (r DataPlaneResource) sensitiveBody(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "example" { + name = "acctest%[2]s" + location = "%[1]s" +} + +resource "azurerm_app_configuration" "appconf" { + name = "acctest%[2]s" + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + sku = "standard" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_role_assignment" "test" { + scope = azurerm_app_configuration.appconf.id + role_definition_name = "App Configuration Data Owner" + principal_id = data.azurerm_client_config.current.object_id +} + +variable "sensitive_value" { + type = string + default = "mysecretvalue" + sensitive = true +} + +resource "azapi_data_plane_resource" "test" { + type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" + parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") + name = "mykey" + body = { + content_type = "" + value = "myvalue" + } + sensitive_body = { + value = var.sensitive_value + } + + depends_on = [ + azurerm_role_assignment.test, + ] +} +`, data.LocationPrimary, data.RandomString) +} + +func (r DataPlaneResource) sensitiveBodyWithVersion(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "example" { + name = "acctest%[2]s" + location = "%[1]s" +} + +resource "azurerm_app_configuration" "appconf" { + name = "acctest%[2]s" + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + sku = "standard" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_role_assignment" "test" { + scope = azurerm_app_configuration.appconf.id + role_definition_name = "App Configuration Data Owner" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "azapi_data_plane_resource" "test" { + type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" + parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") + name = "mykey" + body = { + content_type = "" + value = "myvalue" + } + sensitive_body = { + value = "mysecretvalue" + } + sensitive_body_version = { + "value" = "1" + } + + depends_on = [ + azurerm_role_assignment.test, + ] +} +`, data.LocationPrimary, data.RandomString) +} + +func (r DataPlaneResource) sensitiveBodyWithVersionUpdated(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "example" { + name = "acctest%[2]s" + location = "%[1]s" +} + +resource "azurerm_app_configuration" "appconf" { + name = "acctest%[2]s" + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + sku = "standard" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_role_assignment" "test" { + scope = azurerm_app_configuration.appconf.id + role_definition_name = "App Configuration Data Owner" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "azapi_data_plane_resource" "test" { + type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" + parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") + name = "mykey" + body = { + content_type = "" + value = "myvalue" + } + sensitive_body = { + value = "myupdatedsecretvalue" + } + sensitive_body_version = { + "value" = "2" + } + + depends_on = [ + azurerm_role_assignment.test, + ] +} +`, data.LocationPrimary, data.RandomString) +} + func (DataPlaneResource) Exists(ctx context.Context, client *clients.Client, state *terraform.InstanceState) (*bool, error) { resourceType := state.Attributes["type"] id, err := parse.DataPlaneResourceIDWithResourceType(state.ID, resourceType) From 72ba061ccff57a92983e612b1a9c16ae89f2c7f8 Mon Sep 17 00:00:00 2001 From: Andrew Kaplan Date: Tue, 14 Oct 2025 16:08:27 -0700 Subject: [PATCH 2/2] Add support for sensitive body in azapi_data_plane_resource and add ACC test --- .../services/azapi_data_plane_resource.go | 90 +++--- .../azapi_data_plane_resource_test.go | 271 ++++++------------ .../azapi_data_plane_resource_upgrade_test.go | 2 +- 3 files changed, 135 insertions(+), 228 deletions(-) diff --git a/internal/services/azapi_data_plane_resource.go b/internal/services/azapi_data_plane_resource.go index 705c2cb87..00a2628d3 100644 --- a/internal/services/azapi_data_plane_resource.go +++ b/internal/services/azapi_data_plane_resource.go @@ -292,6 +292,7 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod return } + // Validate that body and sensitive_body don't have conflicting definitions if diags := validateDataPlaneDefinitions(config, config.Body, "body"); diags.HasError() { response.Diagnostics.Append(diags...) return @@ -308,7 +309,7 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod } if state != nil { - // Set output as unknown to trigger a plan diff, if ephemral body has changed + // Set output as unknown to trigger a plan diff, if ephemeral body has changed diff, diags := ephemeralBodyChangeInPlan(ctx, request.Private, config.SensitiveBody, config.SensitiveBodyVersion, state.SensitiveBodyVersion) if response.Diagnostics = append(response.Diagnostics, diags...); response.Diagnostics.HasError() { return @@ -360,48 +361,51 @@ func (r *DataPlaneResource) ModifyPlan(ctx context.Context, request resource.Mod } func (r *DataPlaneResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { - r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics, response.Private) + r.CreateUpdate(ctx, request.Config, request.Plan, &response.State, &response.Diagnostics, response.Private) } func (r *DataPlaneResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { // See if we can skip the external API call (changes are to state only) - var state, plan DataPlaneResourceModel + var plan, state DataPlaneResourceModel if response.Diagnostics.Append(request.Plan.Get(ctx, &plan)...); response.Diagnostics.HasError() { return } if response.Diagnostics.Append(request.State.Get(ctx, &state)...); response.Diagnostics.HasError() { return } - if skip.CanSkipExternalRequest(state, plan, "update") { - tflog.Debug(ctx, "azapi_resource.CreateUpdate skipping external request as no unskippable changes were detected") + if skip.CanSkipExternalRequest(plan, state, "update") { response.Diagnostics.Append(response.State.Set(ctx, plan)...) + tflog.Debug(ctx, "azapi_data_plane_resource.CreateUpdate skipping external request as no unskippable changes were detected") return } - tflog.Debug(ctx, "azapi_resource.CreateUpdate proceeding with external request as no skippable changes were detected") - r.CreateUpdate(ctx, request.Plan, &response.State, &response.Diagnostics, response.Private) + tflog.Debug(ctx, "azapi_data_plane_resource.CreateUpdate proceeding with external request as skippable changes were detected") + r.CreateUpdate(ctx, request.Config, request.Plan, &response.State, &response.Diagnostics, response.Private) } -func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State, diagnostics *diag.Diagnostics, privateData PrivateData) { - var model DataPlaneResourceModel - if diagnostics.Append(plan.Get(ctx, &model)...); diagnostics.HasError() { +func (r *DataPlaneResource) CreateUpdate(ctx context.Context, requestConfig tfsdk.Config, requestPlan tfsdk.Plan, responseState *tfsdk.State, diagnostics *diag.Diagnostics, privateData PrivateData) { + var config, plan, state *DataPlaneResourceModel + diagnostics.Append(requestConfig.Get(ctx, &config)...) + diagnostics.Append(requestPlan.Get(ctx, &plan)...) + diagnostics.Append(responseState.Get(ctx, &state)...) + if diagnostics.HasError() { return } - id, err := parse.NewDataPlaneResourceId(model.Name.ValueString(), model.ParentID.ValueString(), model.Type.ValueString()) + id, err := parse.NewDataPlaneResourceId(plan.Name.ValueString(), plan.ParentID.ValueString(), plan.Type.ValueString()) if err != nil { diagnostics.AddError("Invalid configuration", err.Error()) return } ctx = tflog.SetField(ctx, "resource_id", id.ID()) - isNewResource := state == nil || state.Raw.IsNull() + isNewResource := responseState == nil || responseState.Raw.IsNull() var timeout time.Duration var diags diag.Diagnostics if isNewResource { - timeout, diags = model.Timeouts.Create(ctx, 30*time.Minute) + timeout, diags = plan.Timeouts.Create(ctx, 30*time.Minute) if diagnostics.Append(diags...); diagnostics.HasError() { return } } else { - timeout, diags = model.Timeouts.Update(ctx, 30*time.Minute) + timeout, diags = plan.Timeouts.Update(ctx, 30*time.Minute) if diagnostics.Append(diags...); diagnostics.HasError() { return } @@ -412,7 +416,7 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s if isNewResource { // check if the resource already exists using the non-retry client to avoid issue where user specifies // a FooResourceNotFound error as a retryable error - _, err = r.ProviderData.DataPlaneClient.Get(ctx, id, clients.NewRequestOptions(common.AsMapOfString(model.ReadHeaders), common.AsMapOfLists(model.ReadQueryParameters))) + _, err = r.ProviderData.DataPlaneClient.Get(ctx, id, clients.NewRequestOptions(common.AsMapOfString(plan.ReadHeaders), common.AsMapOfLists(plan.ReadQueryParameters))) if err == nil { diagnostics.AddError("Resource already exists", tf.ImportAsExistsError("azapi_data_plane_resource", id.ID()).Error()) return @@ -423,46 +427,37 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s } } body := make(map[string]interface{}) - if err := unmarshalBody(model.Body, &body); err != nil { + if err := unmarshalBody(plan.Body, &body); err != nil { diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: %s`, err.Error())) return } - var oldModel *DataPlaneResourceModel - if !isNewResource { - oldModel = &DataPlaneResourceModel{} - if diagnostics.Append(state.Get(ctx, oldModel)...); diagnostics.HasError() { - return - } - } - + // Handle sensitive body data sensitiveBodyVersionInState := types.MapNull(types.StringType) - if oldModel != nil { - sensitiveBodyVersionInState = oldModel.SensitiveBodyVersion + if state != nil { + sensitiveBodyVersionInState = state.SensitiveBodyVersion } - - sensitiveBody, err := unmarshalSensitiveBody(model.SensitiveBody, model.SensitiveBodyVersion, sensitiveBodyVersionInState) + sensitiveBody, err := unmarshalSensitiveBody(config.SensitiveBody, plan.SensitiveBodyVersion, sensitiveBodyVersionInState) if err != nil { diagnostics.AddError("Invalid sensitive_body", fmt.Sprintf(`The argument "sensitive_body" is invalid: %s`, err.Error())) return } - body = utils.MergeObject(body, sensitiveBody).(map[string]interface{}) - lockIds := common.AsStringList(model.Locks) + lockIds := common.AsStringList(plan.Locks) slices.Sort(lockIds) for _, lockId := range lockIds { locks.ByID(lockId) defer locks.UnlockByID(lockId) } requestOptions := clients.RequestOptions{ - Headers: common.AsMapOfString(model.CreateHeaders), - QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(model.CreateQueryParameters)), - RetryOptions: clients.NewRetryOptions(model.Retry), + Headers: common.AsMapOfString(plan.CreateHeaders), + QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(plan.CreateQueryParameters)), + RetryOptions: clients.NewRetryOptions(plan.Retry), } if !isNewResource { - requestOptions.Headers = common.AsMapOfString(model.UpdateHeaders) - requestOptions.QueryParameters = clients.NewQueryParameters(common.AsMapOfLists(model.UpdateQueryParameters)) + requestOptions.Headers = common.AsMapOfString(plan.UpdateHeaders) + requestOptions.QueryParameters = clients.NewQueryParameters(common.AsMapOfLists(plan.UpdateQueryParameters)) } _, err = client.CreateOrUpdateThenPoll(ctx, id, body, requestOptions) if err != nil { @@ -470,35 +465,36 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s return } requestOptions = clients.RequestOptions{ - Headers: common.AsMapOfString(model.ReadHeaders), - QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(model.ReadQueryParameters)), + Headers: common.AsMapOfString(plan.ReadHeaders), + QueryParameters: clients.NewQueryParameters(common.AsMapOfLists(plan.ReadQueryParameters)), RetryOptions: clients.CombineRetryOptions( // Create a new retry option to handle specific case of transient 403/404 after resource creation // If a read after create retry is not specified, use the default. clients.NewRetryOptionsForReadAfterCreate(), - clients.NewRetryOptions(model.Retry), + clients.NewRetryOptions(plan.Retry), ), } responseBody, err := client.Get(ctx, id, requestOptions) if err != nil { if utils.ResponseErrorWasNotFound(err) { tflog.Info(ctx, fmt.Sprintf("Error reading %q - removing from state", id.ID())) - state.RemoveResource(ctx) + responseState.RemoveResource(ctx) return } diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error()) return } - model.ID = basetypes.NewStringValue(id.ID()) - output, err := buildOutputFromBody(responseBody, model.ResponseExportValues, nil) + plan.ID = basetypes.NewStringValue(id.ID()) + output, err := buildOutputFromBody(responseBody, plan.ResponseExportValues, nil) if err != nil { diagnostics.AddError("Failed to build output", err.Error()) return } - model.Output = output + plan.Output = output - if model.SensitiveBodyVersion.IsNull() { - writeOnlyBytes, err := dynamic.ToJSON(model.SensitiveBody) + // Store sensitive body information in private data + if plan.SensitiveBodyVersion.IsNull() { + writeOnlyBytes, err := dynamic.ToJSON(config.SensitiveBody) if err != nil { diagnostics.AddError("Invalid sensitive_body", err.Error()) return @@ -508,7 +504,7 @@ func (r *DataPlaneResource) CreateUpdate(ctx context.Context, plan tfsdk.Plan, s diagnostics.Append(ephemeralBodyPrivateMgr.Set(ctx, privateData, nil)...) } - diagnostics.Append(state.Set(ctx, model)...) + diagnostics.Append(responseState.Set(ctx, plan)...) } func (r *DataPlaneResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { @@ -626,8 +622,8 @@ func validateDataPlaneDefinitions(model *DataPlaneResourceModel, body types.Dyna if body.IsNull() || body.IsUnknown() || body.IsUnderlyingValueNull() || body.IsUnderlyingValueUnknown() { return diags } - // DataPlaneResourceModel doesn't have tags, location, or identity fields - // so we only need to check for interface compatibility + // DataPlaneResourceModel doesn't have the same fields as AzapiResourceModel + // so we're just doing basic validation here if _, ok := body.UnderlyingValue().(types.Object); !ok { diags.AddError("Invalid configuration", fmt.Sprintf(`The argument "%s" must be an object`, attributePath)) } diff --git a/internal/services/azapi_data_plane_resource_test.go b/internal/services/azapi_data_plane_resource_test.go index c65d5fd40..6344c4983 100644 --- a/internal/services/azapi_data_plane_resource_test.go +++ b/internal/services/azapi_data_plane_resource_test.go @@ -74,13 +74,12 @@ func TestAccDataPlaneResource_keyVaultIssuer(t *testing.T) { }) } -func TestAccDataPlaneResource_iotAppsUser(t *testing.T) { +func TestAccDataPlaneResource_keyVaultIssuerSensitiveBody(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} - data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.iotAppsUser(data), + Config: r.keyVaultIssuerSensitiveBody(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -89,13 +88,13 @@ func TestAccDataPlaneResource_iotAppsUser(t *testing.T) { }) } -func TestAccDataPlaneResource_timeouts(t *testing.T) { +func TestAccDataPlaneResource_iotAppsUser(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.timeouts(data), + Config: r.iotAppsUser(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -104,13 +103,13 @@ func TestAccDataPlaneResource_timeouts(t *testing.T) { }) } -func TestAccDataPlaneResource_headers(t *testing.T) { +func TestAccDataPlaneResource_timeouts(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.headers(data), + Config: r.timeouts(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -119,13 +118,13 @@ func TestAccDataPlaneResource_headers(t *testing.T) { }) } -func TestAccDataPlaneResource_queryParameters(t *testing.T) { +func TestAccDataPlaneResource_headers(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.queryParameters(data), + Config: r.headers(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -134,13 +133,13 @@ func TestAccDataPlaneResource_queryParameters(t *testing.T) { }) } -func TestAccDataPlaneResource_replaceTriggeredByExternalValues(t *testing.T) { +func TestAccDataPlaneResource_queryParameters(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.replaceTriggeredByExternalValues(data), + Config: r.queryParameters(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -149,33 +148,13 @@ func TestAccDataPlaneResource_replaceTriggeredByExternalValues(t *testing.T) { }) } -func TestAccDataPlaneResource_sensitiveBody(t *testing.T) { +func TestAccDataPlaneResource_replaceTriggeredByExternalValues(t *testing.T) { data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") r := DataPlaneResource{} - data.ResourceTest(t, r, []resource.TestStep{ - { - Config: r.sensitiveBody(data), - ExternalProviders: externalProvidersAzurerm(), - Check: resource.ComposeTestCheckFunc( - check.That(data.ResourceName).ExistsInAzure(r), - ), - }, - }) -} -func TestAccDataPlaneResource_sensitiveBodyVersion(t *testing.T) { - data := acceptance.BuildTestData(t, "azapi_data_plane_resource", "test") - r := DataPlaneResource{} data.ResourceTest(t, r, []resource.TestStep{ { - Config: r.sensitiveBodyWithVersion(data), - ExternalProviders: externalProvidersAzurerm(), - Check: resource.ComposeTestCheckFunc( - check.That(data.ResourceName).ExistsInAzure(r), - ), - }, - { - Config: r.sensitiveBodyWithVersionUpdated(data), + Config: r.replaceTriggeredByExternalValues(data), ExternalProviders: externalProvidersAzurerm(), Check: resource.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), @@ -184,153 +163,6 @@ func TestAccDataPlaneResource_sensitiveBodyVersion(t *testing.T) { }) } -func (r DataPlaneResource) sensitiveBody(data acceptance.TestData) string { - return fmt.Sprintf(` -provider "azurerm" { - features {} -} - -resource "azurerm_resource_group" "example" { - name = "acctest%[2]s" - location = "%[1]s" -} - -resource "azurerm_app_configuration" "appconf" { - name = "acctest%[2]s" - resource_group_name = azurerm_resource_group.example.name - location = azurerm_resource_group.example.location - sku = "standard" -} - -data "azurerm_client_config" "current" {} - -resource "azurerm_role_assignment" "test" { - scope = azurerm_app_configuration.appconf.id - role_definition_name = "App Configuration Data Owner" - principal_id = data.azurerm_client_config.current.object_id -} - -variable "sensitive_value" { - type = string - default = "mysecretvalue" - sensitive = true -} - -resource "azapi_data_plane_resource" "test" { - type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" - parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") - name = "mykey" - body = { - content_type = "" - value = "myvalue" - } - sensitive_body = { - value = var.sensitive_value - } - - depends_on = [ - azurerm_role_assignment.test, - ] -} -`, data.LocationPrimary, data.RandomString) -} - -func (r DataPlaneResource) sensitiveBodyWithVersion(data acceptance.TestData) string { - return fmt.Sprintf(` -provider "azurerm" { - features {} -} - -resource "azurerm_resource_group" "example" { - name = "acctest%[2]s" - location = "%[1]s" -} - -resource "azurerm_app_configuration" "appconf" { - name = "acctest%[2]s" - resource_group_name = azurerm_resource_group.example.name - location = azurerm_resource_group.example.location - sku = "standard" -} - -data "azurerm_client_config" "current" {} - -resource "azurerm_role_assignment" "test" { - scope = azurerm_app_configuration.appconf.id - role_definition_name = "App Configuration Data Owner" - principal_id = data.azurerm_client_config.current.object_id -} - -resource "azapi_data_plane_resource" "test" { - type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" - parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") - name = "mykey" - body = { - content_type = "" - value = "myvalue" - } - sensitive_body = { - value = "mysecretvalue" - } - sensitive_body_version = { - "value" = "1" - } - - depends_on = [ - azurerm_role_assignment.test, - ] -} -`, data.LocationPrimary, data.RandomString) -} - -func (r DataPlaneResource) sensitiveBodyWithVersionUpdated(data acceptance.TestData) string { - return fmt.Sprintf(` -provider "azurerm" { - features {} -} - -resource "azurerm_resource_group" "example" { - name = "acctest%[2]s" - location = "%[1]s" -} - -resource "azurerm_app_configuration" "appconf" { - name = "acctest%[2]s" - resource_group_name = azurerm_resource_group.example.name - location = azurerm_resource_group.example.location - sku = "standard" -} - -data "azurerm_client_config" "current" {} - -resource "azurerm_role_assignment" "test" { - scope = azurerm_app_configuration.appconf.id - role_definition_name = "App Configuration Data Owner" - principal_id = data.azurerm_client_config.current.object_id -} - -resource "azapi_data_plane_resource" "test" { - type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0" - parent_id = replace(azurerm_app_configuration.appconf.endpoint, "https://", "") - name = "mykey" - body = { - content_type = "" - value = "myvalue" - } - sensitive_body = { - value = "myupdatedsecretvalue" - } - sensitive_body_version = { - "value" = "2" - } - - depends_on = [ - azurerm_role_assignment.test, - ] -} -`, data.LocationPrimary, data.RandomString) -} - func (DataPlaneResource) Exists(ctx context.Context, client *clients.Client, state *terraform.InstanceState) (*bool, error) { resourceType := state.Attributes["type"] id, err := parse.DataPlaneResourceIDWithResourceType(state.ID, resourceType) @@ -558,6 +390,85 @@ resource "azapi_data_plane_resource" "test" { `, data.LocationPrimary, data.RandomString) } +func (r DataPlaneResource) keyVaultIssuerSensitiveBody(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "example" { + name = "acctest%[2]s" + location = "%[1]s" +} + +resource "azurerm_key_vault" "example" { + name = "acctest%[2]s" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + enabled_for_disk_encryption = true + tenant_id = data.azurerm_client_config.current.tenant_id + soft_delete_retention_days = 7 + purge_protection_enabled = false + sku_name = "standard" +} + +resource "azapi_resource_action" "add_accesspolicy" { + type = "Microsoft.KeyVault/vaults/accessPolicies@2023-02-01" + resource_id = "${azurerm_key_vault.example.id}/accessPolicies/add" + method = "PUT" + body = { + properties = { + accessPolicies = [{ + tenantId = data.azurerm_client_config.current.tenant_id + objectId = data.azurerm_client_config.current.object_id + permissions = { + certificates = ["managecontacts", "getissuers", "setissuers", "deleteissuers"] + } + }] + } + } +} + +resource "azapi_data_plane_resource" "test" { + type = "Microsoft.KeyVault/vaults/certificates/issuers@7.4" + parent_id = replace(azurerm_key_vault.example.vault_uri, "https://", "") + name = "acctest%[2]s" + body = { + provider = "Test" + org_details = { + admin_details = [ + { + first_name = "John" + last_name = "Doe" + email = "admin@microsoft.com" + phone = "4255555555" + } + ] + } + } + sensitive_body = { + credentials = { + account_id = "keyvaultuser" + password = "sensitive-password" + } + } + sensitive_body_version = { + "credentials.password" = "1" + } + depends_on = [ + azapi_resource_action.add_accesspolicy + ] +} +`, data.LocationPrimary, data.RandomString) +} + func (r DataPlaneResource) iotAppsUser(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { diff --git a/internal/services/azapi_data_plane_resource_upgrade_test.go b/internal/services/azapi_data_plane_resource_upgrade_test.go index 3a77b044e..f2e4c1cb4 100644 --- a/internal/services/azapi_data_plane_resource_upgrade_test.go +++ b/internal/services/azapi_data_plane_resource_upgrade_test.go @@ -12,7 +12,7 @@ import ( func externalProvidersAzurerm() map[string]resource.ExternalProvider { return map[string]resource.ExternalProvider{ "azurerm": { - VersionConstraint: "3.106.0", + VersionConstraint: "4.48.0", Source: "hashicorp/azurerm", }, }