From d9f7381dcaed543da2ee180308d5af1936fa718b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:16:02 +0000 Subject: [PATCH 1/7] Initial plan From e7af822131d0d6a8c9d1a28d4e8e51f3183db6e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:30:31 +0000 Subject: [PATCH 2/7] Implement Plugin Framework role mapping resource and data source Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- CHANGELOG.md | 1 + .../security/role_mapping/acc_test.go | 188 ++++++++++++++++++ .../security/role_mapping/create.go | 15 ++ .../security/role_mapping/data_source.go | 117 +++++++++++ .../security/role_mapping/data_source_test.go | 54 +++++ .../security/role_mapping/delete.go | 33 +++ .../security/role_mapping/models.go | 16 ++ .../security/role_mapping/read.go | 97 +++++++++ .../security/role_mapping/resource.go | 26 +++ .../security/role_mapping/schema.go | 80 ++++++++ .../security/role_mapping/update.go | 97 +++++++++ provider/plugin_framework.go | 3 + 12 files changed, 727 insertions(+) create mode 100644 internal/elasticsearch/security/role_mapping/acc_test.go create mode 100644 internal/elasticsearch/security/role_mapping/create.go create mode 100644 internal/elasticsearch/security/role_mapping/data_source.go create mode 100644 internal/elasticsearch/security/role_mapping/data_source_test.go create mode 100644 internal/elasticsearch/security/role_mapping/delete.go create mode 100644 internal/elasticsearch/security/role_mapping/models.go create mode 100644 internal/elasticsearch/security/role_mapping/read.go create mode 100644 internal/elasticsearch/security/role_mapping/resource.go create mode 100644 internal/elasticsearch/security/role_mapping/schema.go create mode 100644 internal/elasticsearch/security/role_mapping/update.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0798e5f..3ffbadeef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add support for managing cross_cluster API keys in `elasticstack_elasticsearch_security_api_key` ([#1252](https://github.com/elastic/terraform-provider-elasticstack/pull/1252)) - Allow version changes without a destroy/create cycle with `elasticstack_fleet_integration` ([#1255](https://github.com/elastic/terraform-provider-elasticstack/pull/1255)). This fixes an issue where it was impossible to upgrade integrations which are used by an integration policy. - Add `namespace` attribute to `elasticstack_kibana_synthetics_monitor` resource to support setting data stream namespace independently from `space_id` ([#1247](https://github.com/elastic/terraform-provider-elasticstack/pull/1247)) +- Migrate `elasticstack_elasticsearch_security_role_mapping` resource and data source to Terraform Plugin Framework ([#1279](https://github.com/elastic/terraform-provider-elasticstack/pull/1279)) ## [0.11.17] - 2025-07-21 diff --git a/internal/elasticsearch/security/role_mapping/acc_test.go b/internal/elasticsearch/security/role_mapping/acc_test.go new file mode 100644 index 000000000..9e25f375f --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/acc_test.go @@ -0,0 +1,188 @@ +package role_mapping_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/acctest/checks" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccResourceSecurityRoleMapping(t *testing.T) { + roleMappingName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceSecurityRoleMappingDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecurityRoleMappingCreate(roleMappingName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), + checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{"version":1}`), + ), + }, + { + Config: testAccResourceSecurityRoleMappingUpdate(roleMappingName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "false"), + checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin", "user"}), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{}`), + ), + }, + { + Config: testAccResourceSecurityRoleMappingRoleTemplates(roleMappingName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "false"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "role_templates", `[{"format":"json","template":"{\"source\":\"{{#tojson}}groups{{/tojson}}\"}"}]`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{}`), + ), + }, + }, + }) +} + +func TestAccResourceSecurityRoleMappingFromSDK(t *testing.T) { + roleMappingName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + // Create the role mapping with the last provider version where the role mapping resource was built on the SDK + ExternalProviders: map[string]resource.ExternalProvider{ + "elasticstack": { + Source: "elastic/elasticstack", + VersionConstraint: "0.11.17", + }, + }, + Config: testAccResourceSecurityRoleMappingCreate(roleMappingName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), + checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + Config: testAccResourceSecurityRoleMappingCreate(roleMappingName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), + checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), + ), + }, + }, + }) +} + +func checkResourceSecurityRoleMappingDestroy(s *terraform.State) error { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "elasticstack_elasticsearch_security_role_mapping" { + continue + } + compId, _ := clients.CompositeIdFromStr(rs.Primary.ID) + + esClient, err := client.GetESClient() + if err != nil { + return err + } + req := esClient.Security.GetRoleMapping.WithName(compId.ResourceId) + res, err := esClient.Security.GetRoleMapping(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusNotFound { + return fmt.Errorf("Role mapping (%s) still exists", compId.ResourceId) + } + } + return nil +} + +func testAccResourceSecurityRoleMappingCreate(roleMappingName string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_role_mapping" "test" { + name = "%s" + enabled = true + roles = ["admin"] + rules = jsonencode({ + any = [ + { field = { username = "esadmin" } }, + { field = { groups = "cn=admins,dc=example,dc=com" } }, + ] + }) + + metadata = jsonencode({ version = 1 }) +} + `, roleMappingName) +} + +func testAccResourceSecurityRoleMappingUpdate(roleMappingName string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_role_mapping" "test" { + name = "%s" + enabled = false + roles = ["admin", "user"] + rules = jsonencode({ + any = [ + { field = { username = "esadmin" } }, + { field = { groups = "cn=admins,dc=example,dc=com" } }, + ] + }) + + metadata = jsonencode({}) +} + `, roleMappingName) +} + +func testAccResourceSecurityRoleMappingRoleTemplates(roleMappingName string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_role_mapping" "test" { + name = "%s" + enabled = false + role_templates = jsonencode([ + { + format = "json" + template = "{\"source\":\"{{#tojson}}groups{{/tojson}}\"}" + } + ]) + rules = jsonencode({ + any = [ + { field = { username = "esadmin" } }, + { field = { groups = "cn=admins,dc=example,dc=com" } }, + ] + }) + + metadata = jsonencode({}) +} + `, roleMappingName) +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/create.go b/internal/elasticsearch/security/role_mapping/create.go new file mode 100644 index 000000000..2b01c50f3 --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/create.go @@ -0,0 +1,15 @@ +package role_mapping + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *roleMappingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/data_source.go b/internal/elasticsearch/security/role_mapping/data_source.go new file mode 100644 index 000000000..5a09df44a --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/data_source.go @@ -0,0 +1,117 @@ +package role_mapping + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func NewRoleMappingDataSource() datasource.DataSource { + return &roleMappingDataSource{} +} + +type roleMappingDataSource struct { + client *clients.ApiClient +} + +func (d *roleMappingDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_elasticsearch_security_role_mapping" +} + +func (d *roleMappingDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Retrieves role mappings. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-get-role-mapping.html", + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Internal identifier of the resource", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The distinct name that identifies the role mapping, used solely as an identifier.", + Required: true, + }, + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Mappings that have `enabled` set to `false` are ignored when role mapping is performed.", + Computed: true, + }, + "rules": schema.StringAttribute{ + MarkdownDescription: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", + Computed: true, + }, + "roles": schema.SetAttribute{ + MarkdownDescription: "A list of role names that are granted to the users that match the role mapping rules.", + ElementType: types.StringType, + Computed: true, + }, + "role_templates": schema.StringAttribute{ + MarkdownDescription: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", + Computed: true, + }, + "metadata": schema.StringAttribute{ + MarkdownDescription: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", + Computed: true, + }, + }, + } +} + +func (d *roleMappingDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + d.client = client +} + +func (d *roleMappingDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data RoleMappingData + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + roleMappingName := data.Name.ValueString() + id, sdkDiags := d.client.ID(ctx, roleMappingName) + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + if resp.Diagnostics.HasError() { + return + } + + data.Id = types.StringValue(id.String()) + + // Reuse the resource read logic + resourceRead := &roleMappingResource{client: d.client} + + // Create a mock state and request + state := tfsdk.State{ + Schema: GetSchema(), + } + state.Set(ctx, &data) + + readReq := resource.ReadRequest{State: state} + readResp := &resource.ReadResponse{ + State: state, + } + + resourceRead.Read(ctx, readReq, readResp) + resp.Diagnostics.Append(readResp.Diagnostics...) + if resp.Diagnostics.HasError() { + return + } + + var resultData RoleMappingData + resp.Diagnostics.Append(readResp.State.Get(ctx, &resultData)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &resultData)...) +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/data_source_test.go b/internal/elasticsearch/security/role_mapping/data_source_test.go new file mode 100644 index 000000000..a10ec6580 --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/data_source_test.go @@ -0,0 +1,54 @@ +package role_mapping_test + +import ( + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/acctest/checks" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDataSourceSecurityRoleMapping(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceSecurityRoleMapping, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "name", "data_source_test"), + resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), + checks.TestCheckResourceListAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), + resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), + resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{"version":1}`), + ), + }, + }, + }) +} + +const testAccDataSourceSecurityRoleMapping = ` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_role_mapping" "test" { + name = "data_source_test" + enabled = true + roles = [ + "admin" + ] + rules = jsonencode({ + any = [ + { field = { username = "esadmin" } }, + { field = { groups = "cn=admins,dc=example,dc=com" } }, + ] + }) + + metadata = jsonencode({ version = 1 }) +} + +data "elasticstack_elasticsearch_security_role_mapping" "test" { + name = elasticstack_elasticsearch_security_role_mapping.test.name +} +` \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/delete.go b/internal/elasticsearch/security/role_mapping/delete.go new file mode 100644 index 000000000..1ed72612f --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/delete.go @@ -0,0 +1,33 @@ +package role_mapping + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *roleMappingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data RoleMappingData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + sdkDiags := elasticsearch.DeleteRoleMapping(ctx, client, compId.ResourceId) + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/models.go b/internal/elasticsearch/security/role_mapping/models.go new file mode 100644 index 000000000..8200ab0b9 --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/models.go @@ -0,0 +1,16 @@ +package role_mapping + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type RoleMappingData struct { + Id types.String `tfsdk:"id"` + ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` + Name types.String `tfsdk:"name"` + Enabled types.Bool `tfsdk:"enabled"` + Rules types.String `tfsdk:"rules"` + Roles types.Set `tfsdk:"roles"` + RoleTemplates types.String `tfsdk:"role_templates"` + Metadata types.String `tfsdk:"metadata"` +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/read.go b/internal/elasticsearch/security/role_mapping/read.go new file mode 100644 index 000000000..4fa1a991e --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/read.go @@ -0,0 +1,97 @@ +package role_mapping + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data RoleMappingData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + roleMappingName := compId.ResourceId + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + roleMapping, sdkDiags := elasticsearch.GetRoleMapping(ctx, client, roleMappingName) + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + if resp.Diagnostics.HasError() { + return + } + + if roleMapping == nil { + tflog.Warn(ctx, fmt.Sprintf(`Role mapping "%s" not found, removing from state`, roleMappingName)) + resp.State.RemoveResource(ctx) + return + } + + data.Name = types.StringValue(roleMapping.Name) + data.Enabled = types.BoolValue(roleMapping.Enabled) + + // Handle rules + rulesJSON, err := json.Marshal(roleMapping.Rules) + if err != nil { + resp.Diagnostics.AddError("Failed to marshal rules", err.Error()) + return + } + data.Rules = types.StringValue(string(rulesJSON)) + + // Handle roles + if len(roleMapping.Roles) > 0 { + rolesValues := make([]attr.Value, len(roleMapping.Roles)) + for i, role := range roleMapping.Roles { + rolesValues[i] = types.StringValue(role) + } + rolesSet, diags := types.SetValue(types.StringType, rolesValues) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.Roles = rolesSet + } else { + data.Roles = types.SetNull(types.StringType) + } + + // Handle role templates + if len(roleMapping.RoleTemplates) > 0 { + roleTemplatesJSON, err := json.Marshal(roleMapping.RoleTemplates) + if err != nil { + resp.Diagnostics.AddError("Failed to marshal role templates", err.Error()) + return + } + data.RoleTemplates = types.StringValue(string(roleTemplatesJSON)) + } else { + data.RoleTemplates = types.StringNull() + } + + // Handle metadata + metadataJSON, err := json.Marshal(roleMapping.Metadata) + if err != nil { + resp.Diagnostics.AddError("Failed to marshal metadata", err.Error()) + return + } + data.Metadata = types.StringValue(string(metadataJSON)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/resource.go b/internal/elasticsearch/security/role_mapping/resource.go new file mode 100644 index 000000000..56f585c60 --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/resource.go @@ -0,0 +1,26 @@ +package role_mapping + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func NewRoleMappingResource() resource.Resource { + return &roleMappingResource{} +} + +type roleMappingResource struct { + client *clients.ApiClient +} + +func (r *roleMappingResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_elasticsearch_security_role_mapping" +} + +func (r *roleMappingResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/schema.go b/internal/elasticsearch/security/role_mapping/schema.go new file mode 100644 index 000000000..bf5b17e3b --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/schema.go @@ -0,0 +1,80 @@ +package role_mapping + +import ( + "context" + "regexp" + + providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *roleMappingResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = GetSchema() +} + +func GetSchema() schema.Schema { + return schema.Schema{ + MarkdownDescription: "Manage role mappings. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role-mapping.html", + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Internal identifier of the resource", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The distinct name that identifies the role mapping, used solely as an identifier.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 1024), + stringvalidator.RegexMatches(regexp.MustCompile(`^[[:graph:]]+$`), "must contain printable characters and no spaces"), + }, + }, + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Mappings that have `enabled` set to `false` are ignored when role mapping is performed.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "rules": schema.StringAttribute{ + MarkdownDescription: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", + Required: true, + }, + "roles": schema.SetAttribute{ + MarkdownDescription: "A list of role names that are granted to the users that match the role mapping rules.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + setvalidator.ExactlyOneOf(path.MatchRoot("role_templates")), + }, + }, + "role_templates": schema.StringAttribute{ + MarkdownDescription: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.MatchRoot("roles")), + }, + }, + "metadata": schema.StringAttribute{ + MarkdownDescription: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("{}"), + }, + }, + } +} \ No newline at end of file diff --git a/internal/elasticsearch/security/role_mapping/update.go b/internal/elasticsearch/security/role_mapping/update.go new file mode 100644 index 000000000..fbeac5975 --- /dev/null +++ b/internal/elasticsearch/security/role_mapping/update.go @@ -0,0 +1,97 @@ +package role_mapping + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics { + var data RoleMappingData + var diags diag.Diagnostics + diags.Append(plan.Get(ctx, &data)...) + if diags.HasError() { + return diags + } + + roleMappingName := data.Name.ValueString() + id, sdkDiags := r.client.ID(ctx, roleMappingName) + diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + client, frameworkDiags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + diags.Append(frameworkDiags...) + if diags.HasError() { + return diags + } + + // Parse rules JSON + var rules map[string]interface{} + if err := json.Unmarshal([]byte(data.Rules.ValueString()), &rules); err != nil { + diags.AddError("Failed to parse rules JSON", err.Error()) + return diags + } + + // Parse metadata JSON + metadata := json.RawMessage(data.Metadata.ValueString()) + + // Prepare role mapping + roleMapping := models.RoleMapping{ + Name: roleMappingName, + Enabled: data.Enabled.ValueBool(), + Rules: rules, + Metadata: metadata, + } + + // Handle roles or role templates + if !data.Roles.IsNull() && !data.Roles.IsUnknown() { + var roles []string + rolesElements := make([]types.String, 0, len(data.Roles.Elements())) + diags.Append(data.Roles.ElementsAs(ctx, &rolesElements, false)...) + if diags.HasError() { + return diags + } + for _, role := range rolesElements { + roles = append(roles, role.ValueString()) + } + roleMapping.Roles = roles + } + + if !data.RoleTemplates.IsNull() && !data.RoleTemplates.IsUnknown() { + var roleTemplates []map[string]interface{} + if err := json.Unmarshal([]byte(data.RoleTemplates.ValueString()), &roleTemplates); err != nil { + diags.AddError("Failed to parse role templates JSON", err.Error()) + return diags + } + roleMapping.RoleTemplates = roleTemplates + } + + // Put role mapping + sdkDiags = elasticsearch.PutRoleMapping(ctx, client, &roleMapping) + diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + data.Id = types.StringValue(id.String()) + diags.Append(state.Set(ctx, &data)...) + return diags +} + +func (r *roleMappingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} \ No newline at end of file diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index d7031c9ce..4cb4408fe 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -11,6 +11,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/index" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/indices" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/api_key" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/role_mapping" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/system_user" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/agent_policy" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/enrollment_tokens" @@ -88,6 +89,7 @@ func (p *Provider) DataSources(ctx context.Context) []func() datasource.DataSour enrollment_tokens.NewDataSource, integration_ds.NewDataSource, enrich.NewEnrichPolicyDataSource, + role_mapping.NewRoleMappingDataSource, } } @@ -109,5 +111,6 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { server_host.NewResource, system_user.NewSystemUserResource, enrich.NewEnrichPolicyResource, + role_mapping.NewRoleMappingResource, } } From c4160f0aa688f36df1184bb4752de8cf5dc9890a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:31:35 +0000 Subject: [PATCH 3/7] Apply code formatting --- internal/elasticsearch/security/role_mapping/acc_test.go | 2 +- internal/elasticsearch/security/role_mapping/create.go | 2 +- .../elasticsearch/security/role_mapping/data_source.go | 8 ++++---- .../security/role_mapping/data_source_test.go | 2 +- internal/elasticsearch/security/role_mapping/delete.go | 2 +- internal/elasticsearch/security/role_mapping/models.go | 2 +- internal/elasticsearch/security/role_mapping/read.go | 2 +- internal/elasticsearch/security/role_mapping/resource.go | 2 +- internal/elasticsearch/security/role_mapping/schema.go | 2 +- internal/elasticsearch/security/role_mapping/update.go | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/elasticsearch/security/role_mapping/acc_test.go b/internal/elasticsearch/security/role_mapping/acc_test.go index 9e25f375f..24dc5d49e 100644 --- a/internal/elasticsearch/security/role_mapping/acc_test.go +++ b/internal/elasticsearch/security/role_mapping/acc_test.go @@ -185,4 +185,4 @@ resource "elasticstack_elasticsearch_security_role_mapping" "test" { metadata = jsonencode({}) } `, roleMappingName) -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/create.go b/internal/elasticsearch/security/role_mapping/create.go index 2b01c50f3..fd7671795 100644 --- a/internal/elasticsearch/security/role_mapping/create.go +++ b/internal/elasticsearch/security/role_mapping/create.go @@ -12,4 +12,4 @@ func (r *roleMappingResource) Create(ctx context.Context, req resource.CreateReq if resp.Diagnostics.HasError() { return } -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/data_source.go b/internal/elasticsearch/security/role_mapping/data_source.go index 5a09df44a..6a8f9adb2 100644 --- a/internal/elasticsearch/security/role_mapping/data_source.go +++ b/internal/elasticsearch/security/role_mapping/data_source.go @@ -89,18 +89,18 @@ func (d *roleMappingDataSource) Read(ctx context.Context, req datasource.ReadReq // Reuse the resource read logic resourceRead := &roleMappingResource{client: d.client} - + // Create a mock state and request state := tfsdk.State{ Schema: GetSchema(), } state.Set(ctx, &data) - + readReq := resource.ReadRequest{State: state} readResp := &resource.ReadResponse{ State: state, } - + resourceRead.Read(ctx, readReq, readResp) resp.Diagnostics.Append(readResp.Diagnostics...) if resp.Diagnostics.HasError() { @@ -114,4 +114,4 @@ func (d *roleMappingDataSource) Read(ctx context.Context, req datasource.ReadReq } resp.Diagnostics.Append(resp.State.Set(ctx, &resultData)...) -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/data_source_test.go b/internal/elasticsearch/security/role_mapping/data_source_test.go index a10ec6580..2e4c80dc3 100644 --- a/internal/elasticsearch/security/role_mapping/data_source_test.go +++ b/internal/elasticsearch/security/role_mapping/data_source_test.go @@ -51,4 +51,4 @@ resource "elasticstack_elasticsearch_security_role_mapping" "test" { data "elasticstack_elasticsearch_security_role_mapping" "test" { name = elasticstack_elasticsearch_security_role_mapping.test.name } -` \ No newline at end of file +` diff --git a/internal/elasticsearch/security/role_mapping/delete.go b/internal/elasticsearch/security/role_mapping/delete.go index 1ed72612f..40df761ec 100644 --- a/internal/elasticsearch/security/role_mapping/delete.go +++ b/internal/elasticsearch/security/role_mapping/delete.go @@ -30,4 +30,4 @@ func (r *roleMappingResource) Delete(ctx context.Context, req resource.DeleteReq sdkDiags := elasticsearch.DeleteRoleMapping(ctx, client, compId.ResourceId) resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/models.go b/internal/elasticsearch/security/role_mapping/models.go index 8200ab0b9..b62302f93 100644 --- a/internal/elasticsearch/security/role_mapping/models.go +++ b/internal/elasticsearch/security/role_mapping/models.go @@ -13,4 +13,4 @@ type RoleMappingData struct { Roles types.Set `tfsdk:"roles"` RoleTemplates types.String `tfsdk:"role_templates"` Metadata types.String `tfsdk:"metadata"` -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/read.go b/internal/elasticsearch/security/role_mapping/read.go index 4fa1a991e..ce95ade50 100644 --- a/internal/elasticsearch/security/role_mapping/read.go +++ b/internal/elasticsearch/security/role_mapping/read.go @@ -94,4 +94,4 @@ func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest data.Metadata = types.StringValue(string(metadataJSON)) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/resource.go b/internal/elasticsearch/security/role_mapping/resource.go index 56f585c60..a5fe9097c 100644 --- a/internal/elasticsearch/security/role_mapping/resource.go +++ b/internal/elasticsearch/security/role_mapping/resource.go @@ -23,4 +23,4 @@ func (r *roleMappingResource) Configure(_ context.Context, req resource.Configur client, diags := clients.ConvertProviderData(req.ProviderData) resp.Diagnostics.Append(diags...) r.client = client -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/schema.go b/internal/elasticsearch/security/role_mapping/schema.go index bf5b17e3b..8967f0b71 100644 --- a/internal/elasticsearch/security/role_mapping/schema.go +++ b/internal/elasticsearch/security/role_mapping/schema.go @@ -77,4 +77,4 @@ func GetSchema() schema.Schema { }, }, } -} \ No newline at end of file +} diff --git a/internal/elasticsearch/security/role_mapping/update.go b/internal/elasticsearch/security/role_mapping/update.go index fbeac5975..8ff3b3264 100644 --- a/internal/elasticsearch/security/role_mapping/update.go +++ b/internal/elasticsearch/security/role_mapping/update.go @@ -94,4 +94,4 @@ func (r *roleMappingResource) Update(ctx context.Context, req resource.UpdateReq if resp.Diagnostics.HasError() { return } -} \ No newline at end of file +} From 3c6e7a1233c68eded1a495e0c33df99bdcb0eea5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:33:20 +0000 Subject: [PATCH 4/7] Remove old SDKv2 registrations and generate docs --- docs/data-sources/elasticsearch_security_role_mapping.md | 2 +- docs/resources/elasticsearch_security_role_mapping.md | 2 +- provider/provider.go | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/elasticsearch_security_role_mapping.md b/docs/data-sources/elasticsearch_security_role_mapping.md index ae93d8aaf..01c6ca5ab 100644 --- a/docs/data-sources/elasticsearch_security_role_mapping.md +++ b/docs/data-sources/elasticsearch_security_role_mapping.md @@ -35,7 +35,7 @@ output "user" { ### Optional -- `elasticsearch_connection` (Block List, Max: 1, Deprecated) Elasticsearch connection configuration block. This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead. (see [below for nested schema](#nestedblock--elasticsearch_connection)) +- `elasticsearch_connection` (Block List, Deprecated) Elasticsearch connection configuration block. (see [below for nested schema](#nestedblock--elasticsearch_connection)) ### Read-Only diff --git a/docs/resources/elasticsearch_security_role_mapping.md b/docs/resources/elasticsearch_security_role_mapping.md index c3248fe24..12758977d 100644 --- a/docs/resources/elasticsearch_security_role_mapping.md +++ b/docs/resources/elasticsearch_security_role_mapping.md @@ -46,7 +46,7 @@ output "role" { ### Optional -- `elasticsearch_connection` (Block List, Max: 1, Deprecated) Elasticsearch connection configuration block. This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead. (see [below for nested schema](#nestedblock--elasticsearch_connection)) +- `elasticsearch_connection` (Block List, Deprecated) Elasticsearch connection configuration block. (see [below for nested schema](#nestedblock--elasticsearch_connection)) - `enabled` (Boolean) Mappings that have `enabled` set to `false` are ignored when role mapping is performed. - `metadata` (String) Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage. - `role_templates` (String) A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules. diff --git a/provider/provider.go b/provider/provider.go index 2cd6441ca..f9c02a835 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -73,7 +73,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_ingest_processor_uri_parts": ingest.DataSourceProcessorUriParts(), "elasticstack_elasticsearch_ingest_processor_user_agent": ingest.DataSourceProcessorUserAgent(), "elasticstack_elasticsearch_security_role": security.DataSourceRole(), - "elasticstack_elasticsearch_security_role_mapping": security.DataSourceRoleMapping(), "elasticstack_elasticsearch_security_user": security.DataSourceUser(), "elasticstack_elasticsearch_snapshot_repository": cluster.DataSourceSnapshotRespository(), "elasticstack_elasticsearch_info": cluster.DataSourceClusterInfo(), @@ -90,7 +89,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(), "elasticstack_elasticsearch_logstash_pipeline": logstash.ResourceLogstashPipeline(), "elasticstack_elasticsearch_security_role": security.ResourceRole(), - "elasticstack_elasticsearch_security_role_mapping": security.ResourceRoleMapping(), "elasticstack_elasticsearch_security_user": security.ResourceUser(), "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), From 6a1e17a4a9875ba62d268fdf8d366bf8adaeff9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 06:17:16 +0000 Subject: [PATCH 5/7] Address review comments: extract read logic, use normalized JSON types, use framework diagnostics, and cleanup Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/clients/elasticsearch/security.go | 53 +++-- .../elasticsearch/security/role_mapping.go | 198 ------------------ .../security/role_mapping/data_source.go | 41 ++-- .../security/role_mapping/delete.go | 3 +- .../security/role_mapping/models.go | 17 +- .../security/role_mapping/read.go | 107 ++++++---- .../security/role_mapping/schema.go | 4 + .../security/role_mapping/update.go | 29 +-- .../security/role_mapping_data_source.go | 77 ------- .../security/role_mapping_test.go | 156 -------------- provider/provider.go | 28 +-- 11 files changed, 163 insertions(+), 550 deletions(-) delete mode 100644 internal/elasticsearch/security/role_mapping.go delete mode 100644 internal/elasticsearch/security/role_mapping_data_source.go delete mode 100644 internal/elasticsearch/security/role_mapping_test.go diff --git a/internal/clients/elasticsearch/security.go b/internal/clients/elasticsearch/security.go index bfeb5e3e5..d35cd7bc2 100644 --- a/internal/clients/elasticsearch/security.go +++ b/internal/clients/elasticsearch/security.go @@ -252,73 +252,88 @@ func DeleteRole(ctx context.Context, apiClient *clients.ApiClient, rolename stri return diags } -func PutRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMapping *models.RoleMapping) diag.Diagnostics { +func PutRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMapping *models.RoleMapping) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics roleMappingBytes, err := json.Marshal(roleMapping) if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to marshal role mapping", err.Error()) + return diags } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return diags } res, err := esClient.Security.PutRoleMapping(roleMapping.Name, bytes.NewReader(roleMappingBytes), esClient.Security.PutRoleMapping.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to put role mapping", err.Error()) + return diags } defer res.Body.Close() - if diags := utils.CheckError(res, "Unable to put role mapping"); diags.HasError() { + if sdkDiags := utils.CheckError(res, "Unable to put role mapping"); sdkDiags.HasError() { + diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) return diags } - return nil + return diags } -func GetRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMappingName string) (*models.RoleMapping, diag.Diagnostics) { +func GetRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMappingName string) (*models.RoleMapping, fwdiag.Diagnostics) { + var diags fwdiag.Diagnostics esClient, err := apiClient.GetESClient() if err != nil { - return nil, diag.FromErr(err) + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return nil, diags } req := esClient.Security.GetRoleMapping.WithName(roleMappingName) res, err := esClient.Security.GetRoleMapping(req, esClient.Security.GetRoleMapping.WithContext(ctx)) if err != nil { - return nil, diag.FromErr(err) + diags.AddError("Unable to get role mapping", err.Error()) + return nil, diags } defer res.Body.Close() if res.StatusCode == http.StatusNotFound { - return nil, nil + return nil, diags } - if diags := utils.CheckError(res, "Unable to get a role mapping."); diags.HasError() { + if sdkDiags := utils.CheckError(res, "Unable to get a role mapping."); sdkDiags.HasError() { + diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) return nil, diags } roleMappings := make(map[string]models.RoleMapping) if err := json.NewDecoder(res.Body).Decode(&roleMappings); err != nil { - return nil, diag.FromErr(err) + diags.AddError("Unable to decode role mapping response", err.Error()) + return nil, diags } if roleMapping, ok := roleMappings[roleMappingName]; ok { roleMapping.Name = roleMappingName - return &roleMapping, nil + return &roleMapping, diags } - return nil, diag.Errorf("unable to find role mapping '%s' in the cluster", roleMappingName) + diags.AddError("Role mapping not found", fmt.Sprintf("unable to find role mapping '%s' in the cluster", roleMappingName)) + return nil, diags } -func DeleteRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMappingName string) diag.Diagnostics { +func DeleteRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMappingName string) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return diags } res, err := esClient.Security.DeleteRoleMapping(roleMappingName, esClient.Security.DeleteRoleMapping.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to delete role mapping", err.Error()) + return diags } defer res.Body.Close() - if diags := utils.CheckError(res, "Unable to delete role mapping"); diags.HasError() { + if sdkDiags := utils.CheckError(res, "Unable to delete role mapping"); sdkDiags.HasError() { + diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) return diags } - return nil + return diags } func CreateApiKey(apiClient *clients.ApiClient, apikey *models.ApiKey) (*models.ApiKeyCreateResponse, fwdiag.Diagnostics) { diff --git a/internal/elasticsearch/security/role_mapping.go b/internal/elasticsearch/security/role_mapping.go deleted file mode 100644 index fa1f7c780..000000000 --- a/internal/elasticsearch/security/role_mapping.go +++ /dev/null @@ -1,198 +0,0 @@ -package security - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/models" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func ResourceRoleMapping() *schema.Resource { - roleMappingSchema := map[string]*schema.Schema{ - "id": { - Description: "Internal identifier of the resource", - Type: schema.TypeString, - Computed: true, - }, - "name": { - Type: schema.TypeString, - Required: true, - Description: "The distinct name that identifies the role mapping, used solely as an identifier.", - ForceNew: true, - }, - "enabled": { - Type: schema.TypeBool, - Optional: true, - Default: true, - Description: "Mappings that have `enabled` set to `false` are ignored when role mapping is performed.", - }, - "rules": { - Type: schema.TypeString, - Required: true, - DiffSuppressFunc: utils.DiffJsonSuppress, - Description: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", - }, - "roles": { - Type: schema.TypeSet, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "A list of role names that are granted to the users that match the role mapping rules.", - Optional: true, - ConflictsWith: []string{"role_templates"}, - ExactlyOneOf: []string{"roles", "role_templates"}, - }, - "role_templates": { - Type: schema.TypeString, - DiffSuppressFunc: utils.DiffJsonSuppress, - Description: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", - Optional: true, - ConflictsWith: []string{"roles"}, - ExactlyOneOf: []string{"roles", "role_templates"}, - }, - "metadata": { - Type: schema.TypeString, - Optional: true, - Default: "{}", - DiffSuppressFunc: utils.DiffJsonSuppress, - Description: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", - }, - } - - utils.AddConnectionSchema(roleMappingSchema) - - return &schema.Resource{ - Description: "Manage role mappings. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role-mapping.html", - - CreateContext: resourceSecurityRoleMappingPut, - UpdateContext: resourceSecurityRoleMappingPut, - ReadContext: resourceSecurityRoleMappingRead, - DeleteContext: resourceSecurityRoleMappingDelete, - - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - - Schema: roleMappingSchema, - } -} - -func resourceSecurityRoleMappingPut(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - roleMappingName := d.Get("name").(string) - id, diags := client.ID(ctx, roleMappingName) - if diags.HasError() { - return diags - } - - var rules map[string]interface{} - if err := json.Unmarshal([]byte(d.Get("rules").(string)), &rules); err != nil { - return diag.FromErr(err) - } - - var roleTemplates []map[string]interface{} - if t, ok := d.GetOk("role_templates"); ok && t.(string) != "" { - if err := json.Unmarshal([]byte(t.(string)), &roleTemplates); err != nil { - return diag.FromErr(err) - } - } - - roleMapping := models.RoleMapping{ - Name: roleMappingName, - Enabled: d.Get("enabled").(bool), - Roles: utils.ExpandStringSet(d.Get("roles").(*schema.Set)), - RoleTemplates: roleTemplates, - Rules: rules, - Metadata: json.RawMessage(d.Get("metadata").(string)), - } - if diags := elasticsearch.PutRoleMapping(ctx, client, &roleMapping); diags.HasError() { - return diags - } - d.SetId(id.String()) - - return resourceSecurityRoleMappingRead(ctx, d, meta) -} - -func resourceSecurityRoleMappingRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - resourceID, diags := clients.ResourceIDFromStr(d.Id()) - if diags.HasError() { - return diags - } - roleMapping, diags := elasticsearch.GetRoleMapping(ctx, client, resourceID) - if roleMapping == nil && diags == nil { - tflog.Warn(ctx, fmt.Sprintf(`Role mapping "%s" not found, removing from state`, resourceID)) - d.SetId("") - return diags - } - if diags.HasError() { - return diags - } - - rules, err := json.Marshal(roleMapping.Rules) - if err != nil { - return diag.FromErr(err) - } - - metadata, err := json.Marshal(roleMapping.Metadata) - if err != nil { - return diag.FromErr(err) - } - - if err := d.Set("name", roleMapping.Name); err != nil { - return diag.FromErr(err) - } - if len(roleMapping.Roles) > 0 { - if err := d.Set("roles", roleMapping.Roles); err != nil { - return diag.FromErr(err) - } - } - if len(roleMapping.RoleTemplates) > 0 { - roleTemplates, err := json.Marshal(roleMapping.RoleTemplates) - if err != nil { - return diag.FromErr(err) - } - - if err := d.Set("role_templates", string(roleTemplates)); err != nil { - return diag.FromErr(err) - } - } - if err := d.Set("enabled", roleMapping.Enabled); err != nil { - return diag.FromErr(err) - } - if err := d.Set("rules", string(rules)); err != nil { - return diag.FromErr(err) - } - if err := d.Set("metadata", string(metadata)); err != nil { - return diag.FromErr(err) - } - return nil -} - -func resourceSecurityRoleMappingDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - resourceID, diags := clients.ResourceIDFromStr(d.Id()) - if diags.HasError() { - return diags - } - if diags := elasticsearch.DeleteRoleMapping(ctx, client, resourceID); diags.HasError() { - return diags - } - return nil -} diff --git a/internal/elasticsearch/security/role_mapping/data_source.go b/internal/elasticsearch/security/role_mapping/data_source.go index 6a8f9adb2..fc62bcc3f 100644 --- a/internal/elasticsearch/security/role_mapping/data_source.go +++ b/internal/elasticsearch/security/role_mapping/data_source.go @@ -2,14 +2,13 @@ package role_mapping import ( "context" + "fmt" "github.com/elastic/terraform-provider-elasticstack/internal/clients" providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -79,39 +78,35 @@ func (d *roleMappingDataSource) Read(ctx context.Context, req datasource.ReadReq } roleMappingName := data.Name.ValueString() - id, sdkDiags := d.client.ID(ctx, roleMappingName) - resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, d.client) + resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - data.Id = types.StringValue(id.String()) - - // Reuse the resource read logic - resourceRead := &roleMappingResource{client: d.client} - - // Create a mock state and request - state := tfsdk.State{ - Schema: GetSchema(), + id, sdkDiags := client.ID(ctx, roleMappingName) + resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + if resp.Diagnostics.HasError() { + return } - state.Set(ctx, &data) - readReq := resource.ReadRequest{State: state} - readResp := &resource.ReadResponse{ - State: state, - } + data.Id = types.StringValue(id.String()) - resourceRead.Read(ctx, readReq, readResp) - resp.Diagnostics.Append(readResp.Diagnostics...) + // Use the extracted read function + readData, readDiags := readRoleMapping(ctx, client, roleMappingName, data.ElasticsearchConnection) + resp.Diagnostics.Append(readDiags...) if resp.Diagnostics.HasError() { return } - var resultData RoleMappingData - resp.Diagnostics.Append(readResp.State.Get(ctx, &resultData)...) - if resp.Diagnostics.HasError() { + if readData == nil { + resp.Diagnostics.AddError( + "Role mapping not found", + fmt.Sprintf("Role mapping '%s' not found", roleMappingName), + ) return } - resp.Diagnostics.Append(resp.State.Set(ctx, &resultData)...) + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) } diff --git a/internal/elasticsearch/security/role_mapping/delete.go b/internal/elasticsearch/security/role_mapping/delete.go index 40df761ec..66acdcce2 100644 --- a/internal/elasticsearch/security/role_mapping/delete.go +++ b/internal/elasticsearch/security/role_mapping/delete.go @@ -5,7 +5,6 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/resource" ) @@ -29,5 +28,5 @@ func (r *roleMappingResource) Delete(ctx context.Context, req resource.DeleteReq } sdkDiags := elasticsearch.DeleteRoleMapping(ctx, client, compId.ResourceId) - resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + resp.Diagnostics.Append(sdkDiags...) } diff --git a/internal/elasticsearch/security/role_mapping/models.go b/internal/elasticsearch/security/role_mapping/models.go index b62302f93..293efb611 100644 --- a/internal/elasticsearch/security/role_mapping/models.go +++ b/internal/elasticsearch/security/role_mapping/models.go @@ -1,16 +1,17 @@ package role_mapping import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/types" ) type RoleMappingData struct { - Id types.String `tfsdk:"id"` - ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` - Name types.String `tfsdk:"name"` - Enabled types.Bool `tfsdk:"enabled"` - Rules types.String `tfsdk:"rules"` - Roles types.Set `tfsdk:"roles"` - RoleTemplates types.String `tfsdk:"role_templates"` - Metadata types.String `tfsdk:"metadata"` + Id types.String `tfsdk:"id"` + ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` + Name types.String `tfsdk:"name"` + Enabled types.Bool `tfsdk:"enabled"` + Rules jsontypes.Normalized `tfsdk:"rules"` + Roles types.Set `tfsdk:"roles"` + RoleTemplates jsontypes.Normalized `tfsdk:"role_templates"` + Metadata jsontypes.Normalized `tfsdk:"metadata"` } diff --git a/internal/elasticsearch/security/role_mapping/read.go b/internal/elasticsearch/security/role_mapping/read.go index ce95ade50..33d9a9ae6 100644 --- a/internal/elasticsearch/security/role_mapping/read.go +++ b/internal/elasticsearch/security/role_mapping/read.go @@ -8,54 +8,48 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) -func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data RoleMappingData - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } +// readRoleMapping reads role mapping data from Elasticsearch and returns RoleMappingData +func readRoleMapping(ctx context.Context, client *clients.ApiClient, roleMappingName string, elasticsearchConnection types.List) (*RoleMappingData, diag.Diagnostics) { + var diags diag.Diagnostics - compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + roleMapping, apiDiags := elasticsearch.GetRoleMapping(ctx, client, roleMappingName) + diags.Append(apiDiags...) + if diags.HasError() { + return nil, diags } - roleMappingName := compId.ResourceId - client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + if roleMapping == nil { + return nil, diags } - roleMapping, sdkDiags := elasticsearch.GetRoleMapping(ctx, client, roleMappingName) - resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) - if resp.Diagnostics.HasError() { - return - } + data := &RoleMappingData{} - if roleMapping == nil { - tflog.Warn(ctx, fmt.Sprintf(`Role mapping "%s" not found, removing from state`, roleMappingName)) - resp.State.RemoveResource(ctx) - return + // Set basic fields + compId, compDiags := client.ID(ctx, roleMappingName) + diags.Append(utils.FrameworkDiagsFromSDK(compDiags)...) + if diags.HasError() { + return nil, diags } - + data.Id = types.StringValue(compId.String()) + data.ElasticsearchConnection = elasticsearchConnection data.Name = types.StringValue(roleMapping.Name) data.Enabled = types.BoolValue(roleMapping.Enabled) // Handle rules rulesJSON, err := json.Marshal(roleMapping.Rules) if err != nil { - resp.Diagnostics.AddError("Failed to marshal rules", err.Error()) - return + diags.AddError("Failed to marshal rules", err.Error()) + return nil, diags } - data.Rules = types.StringValue(string(rulesJSON)) + data.Rules = jsontypes.NewNormalizedValue(string(rulesJSON)) // Handle roles if len(roleMapping.Roles) > 0 { @@ -63,10 +57,10 @@ func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest for i, role := range roleMapping.Roles { rolesValues[i] = types.StringValue(role) } - rolesSet, diags := types.SetValue(types.StringType, rolesValues) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + rolesSet, setDiags := types.SetValue(types.StringType, rolesValues) + diags.Append(setDiags...) + if diags.HasError() { + return nil, diags } data.Roles = rolesSet } else { @@ -77,21 +71,56 @@ func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest if len(roleMapping.RoleTemplates) > 0 { roleTemplatesJSON, err := json.Marshal(roleMapping.RoleTemplates) if err != nil { - resp.Diagnostics.AddError("Failed to marshal role templates", err.Error()) - return + diags.AddError("Failed to marshal role templates", err.Error()) + return nil, diags } - data.RoleTemplates = types.StringValue(string(roleTemplatesJSON)) + data.RoleTemplates = jsontypes.NewNormalizedValue(string(roleTemplatesJSON)) } else { - data.RoleTemplates = types.StringNull() + data.RoleTemplates = jsontypes.NewNormalizedNull() } // Handle metadata metadataJSON, err := json.Marshal(roleMapping.Metadata) if err != nil { - resp.Diagnostics.AddError("Failed to marshal metadata", err.Error()) + diags.AddError("Failed to marshal metadata", err.Error()) + return nil, diags + } + data.Metadata = jsontypes.NewNormalizedValue(string(metadataJSON)) + + return data, diags +} + +func (r *roleMappingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data RoleMappingData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + roleMappingName := compId.ResourceId + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + readData, diags := readRoleMapping(ctx, client, roleMappingName, data.ElasticsearchConnection) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readData == nil { + tflog.Warn(ctx, fmt.Sprintf(`Role mapping "%s" not found, removing from state`, roleMappingName)) + resp.State.RemoveResource(ctx) return } - data.Metadata = types.StringValue(string(metadataJSON)) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) } diff --git a/internal/elasticsearch/security/role_mapping/schema.go b/internal/elasticsearch/security/role_mapping/schema.go index 8967f0b71..3685399aa 100644 --- a/internal/elasticsearch/security/role_mapping/schema.go +++ b/internal/elasticsearch/security/role_mapping/schema.go @@ -5,6 +5,7 @@ import ( "regexp" providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" @@ -53,6 +54,7 @@ func GetSchema() schema.Schema { "rules": schema.StringAttribute{ MarkdownDescription: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", Required: true, + CustomType: jsontypes.NormalizedType{}, }, "roles": schema.SetAttribute{ MarkdownDescription: "A list of role names that are granted to the users that match the role mapping rules.", @@ -65,6 +67,7 @@ func GetSchema() schema.Schema { "role_templates": schema.StringAttribute{ MarkdownDescription: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", Optional: true, + CustomType: jsontypes.NormalizedType{}, Validators: []validator.String{ stringvalidator.ExactlyOneOf(path.MatchRoot("roles")), }, @@ -73,6 +76,7 @@ func GetSchema() schema.Schema { MarkdownDescription: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", Optional: true, Computed: true, + CustomType: jsontypes.NormalizedType{}, Default: stringdefault.StaticString("{}"), }, }, diff --git a/internal/elasticsearch/security/role_mapping/update.go b/internal/elasticsearch/security/role_mapping/update.go index 8ff3b3264..1db1f72e9 100644 --- a/internal/elasticsearch/security/role_mapping/update.go +++ b/internal/elasticsearch/security/role_mapping/update.go @@ -23,11 +23,6 @@ func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state } roleMappingName := data.Name.ValueString() - id, sdkDiags := r.client.ID(ctx, roleMappingName) - diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) - if diags.HasError() { - return diags - } client, frameworkDiags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) diags.Append(frameworkDiags...) @@ -54,7 +49,7 @@ func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state } // Handle roles or role templates - if !data.Roles.IsNull() && !data.Roles.IsUnknown() { + if utils.IsKnown(data.Roles) { var roles []string rolesElements := make([]types.String, 0, len(data.Roles.Elements())) diags.Append(data.Roles.ElementsAs(ctx, &rolesElements, false)...) @@ -67,7 +62,7 @@ func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state roleMapping.Roles = roles } - if !data.RoleTemplates.IsNull() && !data.RoleTemplates.IsUnknown() { + if utils.IsKnown(data.RoleTemplates) { var roleTemplates []map[string]interface{} if err := json.Unmarshal([]byte(data.RoleTemplates.ValueString()), &roleTemplates); err != nil { diags.AddError("Failed to parse role templates JSON", err.Error()) @@ -77,21 +72,27 @@ func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state } // Put role mapping - sdkDiags = elasticsearch.PutRoleMapping(ctx, client, &roleMapping) - diags.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...) + apiDiags := elasticsearch.PutRoleMapping(ctx, client, &roleMapping) + diags.Append(apiDiags...) + if diags.HasError() { + return diags + } + + // Read the updated role mapping to ensure consistent result + readData, readDiags := readRoleMapping(ctx, client, roleMappingName, data.ElasticsearchConnection) + diags.Append(readDiags...) if diags.HasError() { return diags } - data.Id = types.StringValue(id.String()) - diags.Append(state.Set(ctx, &data)...) + if readData != nil { + diags.Append(state.Set(ctx, readData)...) + } + return diags } func (r *roleMappingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { diags := r.update(ctx, req.Plan, &resp.State) resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } } diff --git a/internal/elasticsearch/security/role_mapping_data_source.go b/internal/elasticsearch/security/role_mapping_data_source.go deleted file mode 100644 index 9484ca3fc..000000000 --- a/internal/elasticsearch/security/role_mapping_data_source.go +++ /dev/null @@ -1,77 +0,0 @@ -package security - -import ( - "context" - - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func DataSourceRoleMapping() *schema.Resource { - roleMappingSchema := map[string]*schema.Schema{ - "id": { - Description: "Internal identifier of the resource", - Type: schema.TypeString, - Computed: true, - }, - "name": { - Type: schema.TypeString, - Required: true, - Description: "The distinct name that identifies the role mapping, used solely as an identifier.", - }, - "enabled": { - Type: schema.TypeBool, - Computed: true, - Description: "Mappings that have `enabled` set to `false` are ignored when role mapping is performed.", - }, - "rules": { - Type: schema.TypeString, - Computed: true, - Description: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", - }, - "roles": { - Type: schema.TypeSet, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Computed: true, - Description: "A list of role names that are granted to the users that match the role mapping rules.", - }, - "role_templates": { - Type: schema.TypeString, - Computed: true, - Description: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", - }, - "metadata": { - Type: schema.TypeString, - Computed: true, - Description: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", - }, - } - - utils.AddConnectionSchema(roleMappingSchema) - - return &schema.Resource{ - Description: "Retrieves role mappings. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-get-role-mapping.html", - ReadContext: dataSourceSecurityRoleMappingRead, - Schema: roleMappingSchema, - } -} - -func dataSourceSecurityRoleMappingRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - - roleId := d.Get("name").(string) - id, diags := client.ID(ctx, roleId) - if diags.HasError() { - return diags - } - d.SetId(id.String()) - - return resourceSecurityRoleMappingRead(ctx, d, meta) -} diff --git a/internal/elasticsearch/security/role_mapping_test.go b/internal/elasticsearch/security/role_mapping_test.go deleted file mode 100644 index 83c06e162..000000000 --- a/internal/elasticsearch/security/role_mapping_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package security_test - -import ( - "fmt" - "net/http" - "testing" - - "github.com/elastic/terraform-provider-elasticstack/internal/acctest" - "github.com/elastic/terraform-provider-elasticstack/internal/acctest/checks" - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" -) - -func TestResourceRoleMapping(t *testing.T) { - roleMappingName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - CheckDestroy: checkResourceSecurityRoleMappingDestroy, - ProtoV6ProviderFactories: acctest.Providers, - Steps: []resource.TestStep{ - { - Config: testAccResourceSecurityRoleMappingCreate(roleMappingName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "true"), - checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin"}), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{"version":1}`), - ), - }, - { - Config: testAccResourceSecurityRoleMappingUpdate(roleMappingName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "false"), - checks.TestCheckResourceListAttr("elasticstack_elasticsearch_security_role_mapping.test", "roles", []string{"admin", "user"}), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{}`), - ), - }, - { - Config: testAccResourceSecurityRoleMappingRoleTemplates(roleMappingName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "name", roleMappingName), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "enabled", "false"), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "role_templates", `[{"format":"json","template":"{\"source\":\"{{#tojson}}groups{{/tojson}}\"}"}]`), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "rules", `{"any":[{"field":{"username":"esadmin"}},{"field":{"groups":"cn=admins,dc=example,dc=com"}}]}`), - resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role_mapping.test", "metadata", `{}`), - ), - }, - }, - }) -} - -func testAccResourceSecurityRoleMappingCreate(roleMappingName string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_security_role_mapping" "test" { - name = "%s" - enabled = true - roles = [ - "admin" - ] - rules = jsonencode({ - any = [ - { field = { username = "esadmin" } }, - { field = { groups = "cn=admins,dc=example,dc=com" } }, - ] - }) - - metadata = jsonencode({ version = 1 }) -} - `, roleMappingName) -} - -func testAccResourceSecurityRoleMappingUpdate(roleMappingName string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_security_role_mapping" "test" { - name = "%s" - enabled = false - roles = [ - "admin", - "user" - ] - rules = jsonencode({ - any = [ - { field = { username = "esadmin" } }, - { field = { groups = "cn=admins,dc=example,dc=com" } }, - ] - }) -} - `, roleMappingName) -} - -func testAccResourceSecurityRoleMappingRoleTemplates(roleMappingName string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} -} - -resource "elasticstack_elasticsearch_security_role_mapping" "test" { - name = "%s" - enabled = false - role_templates = jsonencode([ - { - template = jsonencode({ source = "{{#tojson}}groups{{/tojson}}" }), - format = "json" - } - ]) - rules = jsonencode({ - any = [ - { field = { username = "esadmin" } }, - { field = { groups = "cn=admins,dc=example,dc=com" } }, - ] - }) -} - `, roleMappingName) -} - -func checkResourceSecurityRoleMappingDestroy(s *terraform.State) error { - client, err := clients.NewAcceptanceTestingClient() - if err != nil { - return err - } - - for _, rs := range s.RootModule().Resources { - if rs.Type != "elasticstack_elasticsearch_security_role_mapping" { - continue - } - compId, _ := clients.CompositeIdFromStr(rs.Primary.ID) - - esClient, err := client.GetESClient() - if err != nil { - return err - } - req := esClient.Security.GetRoleMapping.WithName(compId.ResourceId) - res, err := esClient.Security.GetRoleMapping(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusNotFound { - return fmt.Errorf("role mapping (%s) still exists", compId.ResourceId) - } - } - return nil -} diff --git a/provider/provider.go b/provider/provider.go index f9c02a835..10291a2c9 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -81,20 +81,20 @@ func New(version string) *schema.Provider { "elasticstack_kibana_security_role": kibana.DataSourceRole(), }, ResourcesMap: map[string]*schema.Resource{ - "elasticstack_elasticsearch_cluster_settings": cluster.ResourceSettings(), - "elasticstack_elasticsearch_component_template": index.ResourceComponentTemplate(), - "elasticstack_elasticsearch_data_stream": index.ResourceDataStream(), - "elasticstack_elasticsearch_index_lifecycle": index.ResourceIlm(), - "elasticstack_elasticsearch_index_template": index.ResourceTemplate(), - "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(), - "elasticstack_elasticsearch_logstash_pipeline": logstash.ResourceLogstashPipeline(), - "elasticstack_elasticsearch_security_role": security.ResourceRole(), - "elasticstack_elasticsearch_security_user": security.ResourceUser(), - "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), - "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), - "elasticstack_elasticsearch_script": cluster.ResourceScript(), - "elasticstack_elasticsearch_transform": transform.ResourceTransform(), - "elasticstack_elasticsearch_watch": watcher.ResourceWatch(), + "elasticstack_elasticsearch_cluster_settings": cluster.ResourceSettings(), + "elasticstack_elasticsearch_component_template": index.ResourceComponentTemplate(), + "elasticstack_elasticsearch_data_stream": index.ResourceDataStream(), + "elasticstack_elasticsearch_index_lifecycle": index.ResourceIlm(), + "elasticstack_elasticsearch_index_template": index.ResourceTemplate(), + "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(), + "elasticstack_elasticsearch_logstash_pipeline": logstash.ResourceLogstashPipeline(), + "elasticstack_elasticsearch_security_role": security.ResourceRole(), + "elasticstack_elasticsearch_security_user": security.ResourceUser(), + "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), + "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), + "elasticstack_elasticsearch_script": cluster.ResourceScript(), + "elasticstack_elasticsearch_transform": transform.ResourceTransform(), + "elasticstack_elasticsearch_watch": watcher.ResourceWatch(), "elasticstack_kibana_alerting_rule": kibana.ResourceAlertingRule(), "elasticstack_kibana_space": kibana.ResourceSpace(), From 6727aac530295832dd62d4ca62e671f3bd8b29b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:07:18 +0000 Subject: [PATCH 6/7] Address review comments: simplify diagnostics handling and add normalized JSON types to data source Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/elasticsearch/security/role_mapping/create.go | 6 +----- internal/elasticsearch/security/role_mapping/data_source.go | 4 ++++ internal/elasticsearch/security/role_mapping/delete.go | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/elasticsearch/security/role_mapping/create.go b/internal/elasticsearch/security/role_mapping/create.go index fd7671795..84e3d131e 100644 --- a/internal/elasticsearch/security/role_mapping/create.go +++ b/internal/elasticsearch/security/role_mapping/create.go @@ -7,9 +7,5 @@ import ( ) func (r *roleMappingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - diags := r.update(ctx, req.Plan, &resp.State) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } + resp.Diagnostics.Append(r.update(ctx, req.Plan, &resp.State)...) } diff --git a/internal/elasticsearch/security/role_mapping/data_source.go b/internal/elasticsearch/security/role_mapping/data_source.go index fc62bcc3f..13dcd9641 100644 --- a/internal/elasticsearch/security/role_mapping/data_source.go +++ b/internal/elasticsearch/security/role_mapping/data_source.go @@ -7,6 +7,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" @@ -46,6 +47,7 @@ func (d *roleMappingDataSource) Schema(ctx context.Context, req datasource.Schem "rules": schema.StringAttribute{ MarkdownDescription: "The rules that determine which users should be matched by the mapping. A rule is a logical condition that is expressed by using a JSON DSL.", Computed: true, + CustomType: jsontypes.NormalizedType{}, }, "roles": schema.SetAttribute{ MarkdownDescription: "A list of role names that are granted to the users that match the role mapping rules.", @@ -55,10 +57,12 @@ func (d *roleMappingDataSource) Schema(ctx context.Context, req datasource.Schem "role_templates": schema.StringAttribute{ MarkdownDescription: "A list of mustache templates that will be evaluated to determine the roles names that should granted to the users that match the role mapping rules.", Computed: true, + CustomType: jsontypes.NormalizedType{}, }, "metadata": schema.StringAttribute{ MarkdownDescription: "Additional metadata that helps define which roles are assigned to each user. Keys beginning with `_` are reserved for system usage.", Computed: true, + CustomType: jsontypes.NormalizedType{}, }, }, } diff --git a/internal/elasticsearch/security/role_mapping/delete.go b/internal/elasticsearch/security/role_mapping/delete.go index 66acdcce2..a790f28b8 100644 --- a/internal/elasticsearch/security/role_mapping/delete.go +++ b/internal/elasticsearch/security/role_mapping/delete.go @@ -27,6 +27,5 @@ func (r *roleMappingResource) Delete(ctx context.Context, req resource.DeleteReq return } - sdkDiags := elasticsearch.DeleteRoleMapping(ctx, client, compId.ResourceId) - resp.Diagnostics.Append(sdkDiags...) + resp.Diagnostics.Append(elasticsearch.DeleteRoleMapping(ctx, client, compId.ResourceId)...) } From 1667660b8497a0eefb6b2373dd466ec5cdb0ae9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:40:17 +0000 Subject: [PATCH 7/7] Address review feedback: use utils.SetValueFrom and utils.SetTypeAs for role handling Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- .../security/role_mapping/read.go | 18 ++++-------------- .../security/role_mapping/update.go | 10 ++-------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/internal/elasticsearch/security/role_mapping/read.go b/internal/elasticsearch/security/role_mapping/read.go index 33d9a9ae6..7731db4c8 100644 --- a/internal/elasticsearch/security/role_mapping/read.go +++ b/internal/elasticsearch/security/role_mapping/read.go @@ -9,8 +9,8 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -52,19 +52,9 @@ func readRoleMapping(ctx context.Context, client *clients.ApiClient, roleMapping data.Rules = jsontypes.NewNormalizedValue(string(rulesJSON)) // Handle roles - if len(roleMapping.Roles) > 0 { - rolesValues := make([]attr.Value, len(roleMapping.Roles)) - for i, role := range roleMapping.Roles { - rolesValues[i] = types.StringValue(role) - } - rolesSet, setDiags := types.SetValue(types.StringType, rolesValues) - diags.Append(setDiags...) - if diags.HasError() { - return nil, diags - } - data.Roles = rolesSet - } else { - data.Roles = types.SetNull(types.StringType) + data.Roles = utils.SetValueFrom(ctx, roleMapping.Roles, types.StringType, path.Root("roles"), &diags) + if diags.HasError() { + return nil, diags } // Handle role templates diff --git a/internal/elasticsearch/security/role_mapping/update.go b/internal/elasticsearch/security/role_mapping/update.go index 1db1f72e9..a67755367 100644 --- a/internal/elasticsearch/security/role_mapping/update.go +++ b/internal/elasticsearch/security/role_mapping/update.go @@ -9,9 +9,9 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" ) func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics { @@ -50,16 +50,10 @@ func (r *roleMappingResource) update(ctx context.Context, plan tfsdk.Plan, state // Handle roles or role templates if utils.IsKnown(data.Roles) { - var roles []string - rolesElements := make([]types.String, 0, len(data.Roles.Elements())) - diags.Append(data.Roles.ElementsAs(ctx, &rolesElements, false)...) + roleMapping.Roles = utils.SetTypeAs[string](ctx, data.Roles, path.Root("roles"), &diags) if diags.HasError() { return diags } - for _, role := range rolesElements { - roles = append(roles, role.ValueString()) - } - roleMapping.Roles = roles } if utils.IsKnown(data.RoleTemplates) {