diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32b53502b..e2f921481 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@
- Add support for `unenrollment_timeout` in `elasticstack_fleet_agent_policy` ([#1169](https://github.com/elastic/terraform-provider-elasticstack/issues/1169))
- Handle default value for `allow_restricted_indices` in `elasticstack_elasticsearch_security_api_key` ([#1315](https://github.com/elastic/terraform-provider-elasticstack/pull/1315))
- Fixed `nil` reference in kibana synthetics API client in case of response errors ([#1320](https://github.com/elastic/terraform-provider-elasticstack/pull/1320))
+- Migrate `elasticstack_elasticsearch_security_role` resource to Terraform Plugin Framework ([#1330](https://github.com/elastic/terraform-provider-elasticstack/pull/1330))
## [0.11.17] - 2025-07-21
diff --git a/docs/resources/elasticsearch_security_role.md b/docs/resources/elasticsearch_security_role.md
index 0e8a8cd89..bd5f83ea9 100644
--- a/docs/resources/elasticsearch_security_role.md
+++ b/docs/resources/elasticsearch_security_role.md
@@ -4,12 +4,12 @@
page_title: "elasticstack_elasticsearch_security_role Resource - terraform-provider-elasticstack"
subcategory: "Security"
description: |-
- Adds and updates roles in the native realm. See the security API put role documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role.html for more details.
+ Adds and updates roles in the native realm. See the role API documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role.html for more details.
---
# elasticstack_elasticsearch_security_role (Resource)
-Adds and updates roles in the native realm. See the [security API put role documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role.html) for more details.
+Adds and updates roles in the native realm. See the [role API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role.html) for more details.
## Example Usage
@@ -58,7 +58,7 @@ output "role" {
- `applications` (Block Set) A list of application privilege entries. (see [below for nested schema](#nestedblock--applications))
- `cluster` (Set of String) A list of cluster privileges. These privileges define the cluster level actions that users with this role are able to execute.
- `description` (String) The description of the role.
-- `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))
- `global` (String) An object defining global privileges.
- `indices` (Block Set) A list of indices permissions entries. (see [below for nested schema](#nestedblock--indices))
- `metadata` (String) Optional meta-data.
@@ -111,10 +111,10 @@ Required:
Optional:
- `allow_restricted_indices` (Boolean) Include matching restricted indices in names parameter. Usage is strongly discouraged as it can grant unrestricted operations on critical data, make the entire system unstable or leak sensitive information.
-- `field_security` (Block List, Max: 1) The document fields that the owners of the role have read access to. (see [below for nested schema](#nestedblock--indices--field_security))
+- `field_security` (Attributes List) The document fields that the owners of the role have read access to. (see [below for nested schema](#nestedatt--indices--field_security))
- `query` (String) A search query that defines the documents the owners of the role have read access to.
-
+
### Nested Schema for `indices.field_security`
Optional:
@@ -135,7 +135,7 @@ Required:
Optional:
-- `field_security` (Block List, Max: 1) The document fields that the owners of the role have read access to. (see [below for nested schema](#nestedblock--remote_indices--field_security))
+- `field_security` (Block List) The document fields that the owners of the role have read access to. (see [below for nested schema](#nestedblock--remote_indices--field_security))
- `query` (String) A search query that defines the documents the owners of the role have read access to.
diff --git a/internal/elasticsearch/security/role_test.go b/internal/elasticsearch/security/role/acc_test.go
similarity index 78%
rename from internal/elasticsearch/security/role_test.go
rename to internal/elasticsearch/security/role/acc_test.go
index 6824dd960..3a4553dd5 100644
--- a/internal/elasticsearch/security/role_test.go
+++ b/internal/elasticsearch/security/role/acc_test.go
@@ -1,4 +1,4 @@
-package security_test
+package role_test
import (
"fmt"
@@ -6,7 +6,7 @@ import (
"github.com/elastic/terraform-provider-elasticstack/internal/acctest"
"github.com/elastic/terraform-provider-elasticstack/internal/clients"
- "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security"
+ "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/role"
"github.com/elastic/terraform-provider-elasticstack/internal/versionutils"
sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
@@ -46,11 +46,10 @@ func TestAccResourceSecurityRole(t *testing.T) {
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "run_as.#"),
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "global"),
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "applications.#"),
- resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "indices.0.allow_restricted_indices"),
),
},
{
- SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.MinSupportedRemoteIndicesVersion),
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(role.MinSupportedRemoteIndicesVersion),
Config: testAccResourceSecurityRoleRemoteIndicesCreate(roleNameRemoteIndices),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleNameRemoteIndices),
@@ -65,7 +64,7 @@ func TestAccResourceSecurityRole(t *testing.T) {
),
},
{
- SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.MinSupportedRemoteIndicesVersion),
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(role.MinSupportedRemoteIndicesVersion),
Config: testAccResourceSecurityRoleRemoteIndicesUpdate(roleNameRemoteIndices),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleNameRemoteIndices),
@@ -75,13 +74,12 @@ func TestAccResourceSecurityRole(t *testing.T) {
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "run_as.#"),
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "global"),
resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "applications.#"),
- resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_role.test", "indices.0.allow_restricted_indices"),
resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "remote_indices.*.clusters.*", "test-cluster2"),
resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "remote_indices.*.names.*", "sample2"),
),
},
{
- SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.MinSupportedDescriptionVersion),
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(role.MinSupportedDescriptionVersion),
Config: testAccResourceSecurityRoleDescriptionCreate(roleNameDescription),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleNameDescription),
@@ -89,7 +87,7 @@ func TestAccResourceSecurityRole(t *testing.T) {
),
},
{
- SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.MinSupportedDescriptionVersion),
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(role.MinSupportedDescriptionVersion),
Config: testAccResourceSecurityRoleDescriptionUpdate(roleNameDescription),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleNameDescription),
@@ -100,6 +98,45 @@ func TestAccResourceSecurityRole(t *testing.T) {
})
}
+func TestAccResourceSecurityRoleFromSDK(t *testing.T) {
+ roleName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum)
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { acctest.PreCheck(t) },
+ Steps: []resource.TestStep{
+ {
+ // Create the role with the last provider version where the role resource was built on the SDK
+ ExternalProviders: map[string]resource.ExternalProvider{
+ "elasticstack": {
+ Source: "elastic/elasticstack",
+ VersionConstraint: "0.11.17",
+ },
+ },
+ Config: testAccResourceSecurityRoleCreate(roleName),
+ Check: resource.ComposeTestCheckFunc(
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleName),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "indices.0.allow_restricted_indices", "true"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "indices.*.names.*", "index1"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "indices.*.names.*", "index2"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "cluster.*", "all"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "run_as.*", "other_user"),
+ ),
+ },
+ {
+ ProtoV6ProviderFactories: acctest.Providers,
+ Config: testAccResourceSecurityRoleCreate(roleName),
+ Check: resource.ComposeTestCheckFunc(
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "name", roleName),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_role.test", "indices.0.allow_restricted_indices", "true"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "indices.*.names.*", "index1"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "indices.*.names.*", "index2"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "cluster.*", "all"),
+ resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_role.test", "run_as.*", "other_user"),
+ ),
+ },
+ },
+ })
+}
+
func testAccResourceSecurityRoleCreate(roleName string) string {
return fmt.Sprintf(`
provider "elasticstack" {
diff --git a/internal/elasticsearch/security/role/create.go b/internal/elasticsearch/security/role/create.go
new file mode 100644
index 000000000..64413577a
--- /dev/null
+++ b/internal/elasticsearch/security/role/create.go
@@ -0,0 +1,12 @@
+package role
+
+import (
+ "context"
+
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+)
+
+func (r *roleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ diags := r.update(ctx, req.Plan, &resp.State)
+ resp.Diagnostics.Append(diags...)
+}
diff --git a/internal/elasticsearch/security/role/delete.go b/internal/elasticsearch/security/role/delete.go
new file mode 100644
index 000000000..36979a5c7
--- /dev/null
+++ b/internal/elasticsearch/security/role/delete.go
@@ -0,0 +1,33 @@
+package role
+
+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/diagutil"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+)
+
+func (r *roleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var data RoleData
+ 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.DeleteRole(ctx, client, compId.ResourceId)
+ resp.Diagnostics.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...)
+}
diff --git a/internal/elasticsearch/security/role/models.go b/internal/elasticsearch/security/role/models.go
new file mode 100644
index 000000000..04b4fbc5c
--- /dev/null
+++ b/internal/elasticsearch/security/role/models.go
@@ -0,0 +1,548 @@
+package role
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/elastic/terraform-provider-elasticstack/internal/models"
+ "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/types"
+)
+
+type RoleData struct {
+ Id types.String `tfsdk:"id"`
+ ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"`
+ Name types.String `tfsdk:"name"`
+ Description types.String `tfsdk:"description"`
+ Applications types.Set `tfsdk:"applications"`
+ Global jsontypes.Normalized `tfsdk:"global"`
+ Cluster types.Set `tfsdk:"cluster"`
+ Indices types.Set `tfsdk:"indices"`
+ RemoteIndices types.Set `tfsdk:"remote_indices"`
+ Metadata jsontypes.Normalized `tfsdk:"metadata"`
+ RunAs types.Set `tfsdk:"run_as"`
+}
+
+type ApplicationData struct {
+ Application types.String `tfsdk:"application"`
+ Privileges types.Set `tfsdk:"privileges"`
+ Resources types.Set `tfsdk:"resources"`
+}
+
+type IndexPermsData struct {
+ FieldSecurity types.List `tfsdk:"field_security"`
+ Names types.Set `tfsdk:"names"`
+ Privileges types.Set `tfsdk:"privileges"`
+ Query jsontypes.Normalized `tfsdk:"query"`
+ AllowRestrictedIndices types.Bool `tfsdk:"allow_restricted_indices"`
+}
+
+type RemoteIndexPermsData struct {
+ Clusters types.Set `tfsdk:"clusters"`
+ FieldSecurity types.List `tfsdk:"field_security"`
+ Query jsontypes.Normalized `tfsdk:"query"`
+ Names types.Set `tfsdk:"names"`
+ Privileges types.Set `tfsdk:"privileges"`
+}
+
+type FieldSecurityData struct {
+ Grant types.Set `tfsdk:"grant"`
+ Except types.Set `tfsdk:"except"`
+}
+
+// toAPIModel converts the Terraform model to the API model
+func (data *RoleData) toAPIModel(ctx context.Context) (*models.Role, diag.Diagnostics) {
+ var diags diag.Diagnostics
+ var role models.Role
+
+ role.Name = data.Name.ValueString()
+
+ // Description
+ if utils.IsKnown(data.Description) {
+ description := data.Description.ValueString()
+ role.Description = &description
+ }
+
+ // Applications
+ if utils.IsKnown(data.Applications) {
+ var applicationsList []ApplicationData
+ diags.Append(data.Applications.ElementsAs(ctx, &applicationsList, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ applications := make([]models.Application, len(applicationsList))
+ for i, app := range applicationsList {
+ var privileges, resources []string
+ diags.Append(app.Privileges.ElementsAs(ctx, &privileges, false)...)
+ diags.Append(app.Resources.ElementsAs(ctx, &resources, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ applications[i] = models.Application{
+ Name: app.Application.ValueString(),
+ Privileges: privileges,
+ Resources: resources,
+ }
+ }
+ role.Applications = applications
+ }
+
+ // Global
+ if utils.IsKnown(data.Global) {
+ var global map[string]interface{}
+ if err := json.Unmarshal([]byte(data.Global.ValueString()), &global); err != nil {
+ diags.AddError("Invalid JSON", fmt.Sprintf("Error parsing global JSON: %s", err))
+ return nil, diags
+ }
+ role.Global = global
+ }
+
+ // Cluster
+ if utils.IsKnown(data.Cluster) {
+ var cluster []string
+ diags.Append(data.Cluster.ElementsAs(ctx, &cluster, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ role.Cluster = cluster
+ }
+
+ // Indices
+ if utils.IsKnown(data.Indices) {
+ var indicesList []IndexPermsData
+ diags.Append(data.Indices.ElementsAs(ctx, &indicesList, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ indices := make([]models.IndexPerms, len(indicesList))
+ for i, idx := range indicesList {
+ var names, privileges []string
+ diags.Append(idx.Names.ElementsAs(ctx, &names, false)...)
+ diags.Append(idx.Privileges.ElementsAs(ctx, &privileges, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ newIndex := models.IndexPerms{
+ Names: names,
+ Privileges: privileges,
+ }
+
+ if utils.IsKnown(idx.Query) {
+ query := idx.Query.ValueString()
+ newIndex.Query = &query
+ }
+
+ // Field Security
+ if utils.IsKnown(idx.FieldSecurity) {
+ var fieldSecList []FieldSecurityData
+ diags.Append(idx.FieldSecurity.ElementsAs(ctx, &fieldSecList, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ if len(fieldSecList) > 0 {
+ fieldSec := fieldSecList[0]
+ fieldSecurity := models.FieldSecurity{}
+
+ if utils.IsKnown(fieldSec.Grant) {
+ var grants []string
+ diags.Append(fieldSec.Grant.ElementsAs(ctx, &grants, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ fieldSecurity.Grant = grants
+ }
+
+ if utils.IsKnown(fieldSec.Except) {
+ var excepts []string
+ diags.Append(fieldSec.Except.ElementsAs(ctx, &excepts, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ fieldSecurity.Except = excepts
+ }
+
+ newIndex.FieldSecurity = &fieldSecurity
+ }
+ }
+
+ if utils.IsKnown(idx.AllowRestrictedIndices) {
+ allowRestrictedIndices := idx.AllowRestrictedIndices.ValueBool()
+ newIndex.AllowRestrictedIndices = &allowRestrictedIndices
+ }
+
+ indices[i] = newIndex
+ }
+ role.Indices = indices
+ }
+
+ // Remote Indices
+ if utils.IsKnown(data.RemoteIndices) {
+ var remoteIndicesList []RemoteIndexPermsData
+ diags.Append(data.RemoteIndices.ElementsAs(ctx, &remoteIndicesList, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ remoteIndices := make([]models.RemoteIndexPerms, len(remoteIndicesList))
+ for i, remoteIdx := range remoteIndicesList {
+ var names, clusters, privileges []string
+ diags.Append(remoteIdx.Names.ElementsAs(ctx, &names, false)...)
+ diags.Append(remoteIdx.Clusters.ElementsAs(ctx, &clusters, false)...)
+ diags.Append(remoteIdx.Privileges.ElementsAs(ctx, &privileges, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ newRemoteIndex := models.RemoteIndexPerms{
+ Names: names,
+ Clusters: clusters,
+ Privileges: privileges,
+ }
+
+ if utils.IsKnown(remoteIdx.Query) {
+ query := remoteIdx.Query.ValueString()
+ newRemoteIndex.Query = &query
+ }
+
+ // Field Security
+ if utils.IsKnown(remoteIdx.FieldSecurity) {
+ var fieldSecList []FieldSecurityData
+ diags.Append(remoteIdx.FieldSecurity.ElementsAs(ctx, &fieldSecList, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ if len(fieldSecList) > 0 {
+ fieldSec := fieldSecList[0]
+ remoteFieldSecurity := models.FieldSecurity{}
+
+ if utils.IsKnown(fieldSec.Grant) {
+ var grants []string
+ diags.Append(fieldSec.Grant.ElementsAs(ctx, &grants, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ remoteFieldSecurity.Grant = grants
+ }
+
+ if utils.IsKnown(fieldSec.Except) {
+ var excepts []string
+ diags.Append(fieldSec.Except.ElementsAs(ctx, &excepts, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ remoteFieldSecurity.Except = excepts
+ }
+
+ newRemoteIndex.FieldSecurity = &remoteFieldSecurity
+ }
+ }
+
+ remoteIndices[i] = newRemoteIndex
+ }
+ role.RemoteIndices = remoteIndices
+ }
+
+ // Metadata
+ if utils.IsKnown(data.Metadata) {
+ var metadata map[string]interface{}
+ if err := json.Unmarshal([]byte(data.Metadata.ValueString()), &metadata); err != nil {
+ diags.AddError("Invalid JSON", fmt.Sprintf("Error parsing metadata JSON: %s", err))
+ return nil, diags
+ }
+ role.Metadata = metadata
+ }
+
+ // Run As
+ if utils.IsKnown(data.RunAs) {
+ var runAs []string
+ diags.Append(data.RunAs.ElementsAs(ctx, &runAs, false)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ role.RusAs = runAs
+ }
+
+ return &role, diags
+}
+
+// fromAPIModel converts the API model to the Terraform model
+func (data *RoleData) fromAPIModel(ctx context.Context, role *models.Role) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ data.Name = types.StringValue(role.Name)
+
+ // Description
+ data.Description = types.StringPointerValue(role.Description)
+
+ // Applications
+ if len(role.Applications) > 0 {
+ appElements := make([]attr.Value, len(role.Applications))
+ for i, app := range role.Applications {
+ privSet, d := types.SetValueFrom(ctx, types.StringType, app.Privileges)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ resSet, d := types.SetValueFrom(ctx, types.StringType, app.Resources)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ appObj, d := types.ObjectValue(getApplicationAttrTypes(), map[string]attr.Value{
+ "application": types.StringValue(app.Name),
+ "privileges": privSet,
+ "resources": resSet,
+ })
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ appElements[i] = appObj
+ }
+
+ appSet, d := types.SetValue(types.ObjectType{AttrTypes: getApplicationAttrTypes()}, appElements)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ data.Applications = appSet
+ } else {
+ data.Applications = types.SetNull(types.ObjectType{AttrTypes: getApplicationAttrTypes()})
+ }
+
+ // Cluster
+ if len(role.Cluster) > 0 {
+ clusterSet, d := types.SetValueFrom(ctx, types.StringType, role.Cluster)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ data.Cluster = clusterSet
+ } else {
+ data.Cluster = types.SetNull(types.StringType)
+ }
+
+ // Global
+ if role.Global != nil {
+ global, err := json.Marshal(role.Global)
+ if err != nil {
+ diags.AddError("JSON Marshal Error", fmt.Sprintf("Error marshaling global JSON: %s", err))
+ return diags
+ }
+ data.Global = jsontypes.NewNormalizedValue(string(global))
+ } else {
+ data.Global = jsontypes.NewNormalizedNull()
+ }
+
+ // Indices
+ if len(role.Indices) > 0 {
+ indicesElements := make([]attr.Value, len(role.Indices))
+ for i, index := range role.Indices {
+ namesSet, d := types.SetValueFrom(ctx, types.StringType, index.Names)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ privSet, d := types.SetValueFrom(ctx, types.StringType, index.Privileges)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ var queryVal jsontypes.Normalized
+ if index.Query != nil {
+ queryVal = jsontypes.NewNormalizedValue(*index.Query)
+ } else {
+ queryVal = jsontypes.NewNormalizedNull()
+ }
+
+ var allowRestrictedVal types.Bool
+ if index.AllowRestrictedIndices != nil {
+ allowRestrictedVal = types.BoolValue(*index.AllowRestrictedIndices)
+ } else {
+ allowRestrictedVal = types.BoolNull()
+ }
+
+ var fieldSecList types.List
+ if index.FieldSecurity != nil {
+ grantSet, d := types.SetValueFrom(ctx, types.StringType, index.FieldSecurity.Grant)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ exceptSet, d := types.SetValueFrom(ctx, types.StringType, index.FieldSecurity.Except)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ fieldSecObj, d := types.ObjectValue(getFieldSecurityAttrTypes(), map[string]attr.Value{
+ "grant": grantSet,
+ "except": exceptSet,
+ })
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ fieldSecList, d = types.ListValue(types.ObjectType{AttrTypes: getFieldSecurityAttrTypes()}, []attr.Value{fieldSecObj})
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ } else {
+ fieldSecList = types.ListNull(types.ObjectType{AttrTypes: getFieldSecurityAttrTypes()})
+ }
+
+ indexObj, d := types.ObjectValue(getIndexPermsAttrTypes(), map[string]attr.Value{
+ "field_security": fieldSecList,
+ "names": namesSet,
+ "privileges": privSet,
+ "query": queryVal,
+ "allow_restricted_indices": allowRestrictedVal,
+ })
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ indicesElements[i] = indexObj
+ }
+
+ indicesSet, d := types.SetValue(types.ObjectType{AttrTypes: getIndexPermsAttrTypes()}, indicesElements)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ data.Indices = indicesSet
+ } else {
+ data.Indices = types.SetNull(types.ObjectType{AttrTypes: getIndexPermsAttrTypes()})
+ }
+
+ // Remote Indices
+ if len(role.RemoteIndices) > 0 {
+ remoteIndicesElements := make([]attr.Value, len(role.RemoteIndices))
+ for i, remoteIndex := range role.RemoteIndices {
+ clustersSet, d := types.SetValueFrom(ctx, types.StringType, remoteIndex.Clusters)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ namesSet, d := types.SetValueFrom(ctx, types.StringType, remoteIndex.Names)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ privSet, d := types.SetValueFrom(ctx, types.StringType, remoteIndex.Privileges)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ var queryVal jsontypes.Normalized
+ if remoteIndex.Query != nil {
+ queryVal = jsontypes.NewNormalizedValue(*remoteIndex.Query)
+ } else {
+ queryVal = jsontypes.NewNormalizedNull()
+ }
+
+ var fieldSecList types.List
+ if remoteIndex.FieldSecurity != nil {
+ grantSet, d := types.SetValueFrom(ctx, types.StringType, utils.NonNilSlice(remoteIndex.FieldSecurity.Grant))
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ exceptSet, d := types.SetValueFrom(ctx, types.StringType, utils.NonNilSlice(remoteIndex.FieldSecurity.Except))
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ fieldSecObj, d := types.ObjectValue(getRemoteFieldSecurityAttrTypes(), map[string]attr.Value{
+ "grant": grantSet,
+ "except": exceptSet,
+ })
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ fieldSecList, d = types.ListValue(types.ObjectType{AttrTypes: getRemoteFieldSecurityAttrTypes()}, []attr.Value{fieldSecObj})
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ } else {
+ fieldSecList = types.ListNull(types.ObjectType{AttrTypes: getRemoteFieldSecurityAttrTypes()})
+ }
+
+ remoteIndexObj, d := types.ObjectValue(getRemoteIndexPermsAttrTypes(), map[string]attr.Value{
+ "clusters": clustersSet,
+ "field_security": fieldSecList,
+ "query": queryVal,
+ "names": namesSet,
+ "privileges": privSet,
+ })
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+
+ remoteIndicesElements[i] = remoteIndexObj
+ }
+
+ remoteIndicesSet, d := types.SetValue(types.ObjectType{AttrTypes: getRemoteIndexPermsAttrTypes()}, remoteIndicesElements)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ data.RemoteIndices = remoteIndicesSet
+ } else {
+ data.RemoteIndices = types.SetNull(types.ObjectType{AttrTypes: getRemoteIndexPermsAttrTypes()})
+ }
+
+ // Metadata
+ if role.Metadata != nil {
+ metadata, err := json.Marshal(role.Metadata)
+ if err != nil {
+ diags.AddError("JSON Marshal Error", fmt.Sprintf("Error marshaling metadata JSON: %s", err))
+ return diags
+ }
+ data.Metadata = jsontypes.NewNormalizedValue(string(metadata))
+ } else {
+ data.Metadata = jsontypes.NewNormalizedNull()
+ }
+
+ // Run As
+ if len(role.RusAs) > 0 {
+ runAsSet, d := types.SetValueFrom(ctx, types.StringType, role.RusAs)
+ diags.Append(d...)
+ if diags.HasError() {
+ return diags
+ }
+ data.RunAs = runAsSet
+ } else {
+ data.RunAs = types.SetNull(types.StringType)
+ }
+
+ return diags
+}
diff --git a/internal/elasticsearch/security/role/read.go b/internal/elasticsearch/security/role/read.go
new file mode 100644
index 000000000..ecae5f723
--- /dev/null
+++ b/internal/elasticsearch/security/role/read.go
@@ -0,0 +1,72 @@
+package role
+
+import (
+ "context"
+ "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/diagutil"
+ "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 *roleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var data RoleData
+ resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ readData, diags := r.read(ctx, data)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if readData == nil {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ resp.Diagnostics.Append(req.State.Set(ctx, readData)...)
+}
+
+func (r *roleResource) read(ctx context.Context, data RoleData) (*RoleData, diag.Diagnostics) {
+ compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString())
+ diags.Append(diags...)
+ if diags.HasError() {
+ return nil, diags
+ }
+ roleId := compId.ResourceId
+
+ client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client)
+ diags.Append(diags...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ role, sdkDiags := elasticsearch.GetRole(ctx, client, roleId)
+ diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ if role == nil {
+ tflog.Warn(ctx, fmt.Sprintf(`Role "%s" not found`, roleId))
+ return nil, diags
+ }
+
+ // Convert from API model
+ diags.Append(data.fromAPIModel(ctx, role)...)
+ if diags.HasError() {
+ return nil, diags
+ }
+
+ // Set the name to the roleId we extracted to ensure consistency
+ data.Name = types.StringValue(roleId)
+
+ return &data, diags
+}
diff --git a/internal/elasticsearch/security/role/resource-description.md b/internal/elasticsearch/security/role/resource-description.md
new file mode 100644
index 000000000..9c1d8ca10
--- /dev/null
+++ b/internal/elasticsearch/security/role/resource-description.md
@@ -0,0 +1 @@
+Adds and updates roles in the native realm. See the [role API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-role.html) for more details.
\ No newline at end of file
diff --git a/internal/elasticsearch/security/role/resource.go b/internal/elasticsearch/security/role/resource.go
new file mode 100644
index 000000000..79543f336
--- /dev/null
+++ b/internal/elasticsearch/security/role/resource.go
@@ -0,0 +1,107 @@
+package role
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/elastic/terraform-provider-elasticstack/internal/clients"
+ "github.com/elastic/terraform-provider-elasticstack/internal/utils"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-go/tfprotov6"
+)
+
+// Ensure provider defined types fully satisfy framework interfaces
+var _ resource.Resource = &roleResource{}
+var _ resource.ResourceWithConfigure = &roleResource{}
+var _ resource.ResourceWithImportState = &roleResource{}
+var _ resource.ResourceWithUpgradeState = &roleResource{}
+
+func NewRoleResource() resource.Resource {
+ return &roleResource{}
+}
+
+type roleResource struct {
+ client *clients.ApiClient
+}
+
+func (r *roleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_elasticsearch_security_role"
+}
+
+func (r *roleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ client, diags := clients.ConvertProviderData(req.ProviderData)
+ resp.Diagnostics.Append(diags...)
+ r.client = client
+}
+
+func (r *roleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
+}
+
+func (r *roleResource) UpgradeState(_ context.Context) map[int64]resource.StateUpgrader {
+ return map[int64]resource.StateUpgrader{
+ 0: {
+ PriorSchema: utils.Pointer(GetSchema(0)),
+ StateUpgrader: v0ToV1,
+ },
+ }
+}
+
+func v0ToV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
+ var priorState map[string]interface{}
+ err := json.Unmarshal(req.RawState.JSON, &priorState)
+ if err != nil {
+ resp.Diagnostics.AddError("State Upgrade Error", "Could not unmarshal prior state: "+err.Error())
+ return
+ }
+
+ if priorState["global"] == "" {
+ delete(priorState, "global")
+ }
+
+ if priorState["metadata"] == "" {
+ delete(priorState, "metadata")
+ }
+
+ indices, ok := priorState["indices"]
+ if ok {
+ indicesSlice, ok := indices.([]interface{})
+ if ok {
+ for i, index := range indicesSlice {
+ indexMap, ok := index.(map[string]interface{})
+ if ok {
+ if indexMap["query"] == "" {
+ delete(indexMap, "query")
+ }
+ indicesSlice[i] = indexMap
+ }
+ }
+ }
+ }
+
+ remoteIndices, ok := priorState["remote_indices"]
+ if ok {
+ remoteIndicesSlice, ok := remoteIndices.([]interface{})
+ if ok {
+ for i, remoteIndex := range remoteIndicesSlice {
+ remoteIndexMap, ok := remoteIndex.(map[string]interface{})
+ if ok {
+ if remoteIndexMap["query"] == "" {
+ delete(remoteIndexMap, "query")
+ }
+ remoteIndicesSlice[i] = remoteIndexMap
+ }
+ }
+ }
+ }
+
+ stateJSON, err := json.Marshal(priorState)
+ if err != nil {
+ resp.Diagnostics.AddError("State Upgrade Error", "Could not marshal new state: "+err.Error())
+ return
+ }
+ resp.DynamicValue = &tfprotov6.DynamicValue{
+ JSON: stateJSON,
+ }
+}
diff --git a/internal/elasticsearch/security/role/resource_test.go b/internal/elasticsearch/security/role/resource_test.go
new file mode 100644
index 000000000..d1d52a141
--- /dev/null
+++ b/internal/elasticsearch/security/role/resource_test.go
@@ -0,0 +1,348 @@
+package role
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-go/tfprotov6"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestV0ToV1(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]interface{}
+ expected map[string]interface{}
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "empty_global_and_metadata_removed",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": "",
+ "metadata": "",
+ "cluster": []string{"all"},
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "cluster": []interface{}{"all"},
+ },
+ },
+ {
+ name: "non_empty_global_and_metadata_preserved",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": `{"profile": {"privileges": ["manage"]}}`,
+ "metadata": `{"version": 1}`,
+ "cluster": []string{"all"},
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": `{"profile": {"privileges": ["manage"]}}`,
+ "metadata": `{"version": 1}`,
+ "cluster": []interface{}{"all"},
+ },
+ },
+ {
+ name: "empty_query_in_indices_removed",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": []interface{}{
+ map[string]interface{}{
+ "names": []string{"index1", "index2"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ map[string]interface{}{
+ "names": []string{"index3"},
+ "privileges": []string{"write"},
+ "query": `{"match": {"field": "value"}}`,
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": []interface{}{
+ map[string]interface{}{
+ "names": []interface{}{"index1", "index2"},
+ "privileges": []interface{}{"read"},
+ },
+ map[string]interface{}{
+ "names": []interface{}{"index3"},
+ "privileges": []interface{}{"write"},
+ "query": `{"match": {"field": "value"}}`,
+ },
+ },
+ },
+ },
+ {
+ name: "empty_query_in_remote_indices_removed",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": []interface{}{
+ map[string]interface{}{
+ "clusters": []string{"cluster1"},
+ "names": []string{"remote-index1"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ map[string]interface{}{
+ "clusters": []string{"cluster2"},
+ "names": []string{"remote-index2"},
+ "privileges": []string{"write"},
+ "query": `{"term": {"status": "active"}}`,
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": []interface{}{
+ map[string]interface{}{
+ "clusters": []interface{}{"cluster1"},
+ "names": []interface{}{"remote-index1"},
+ "privileges": []interface{}{"read"},
+ },
+ map[string]interface{}{
+ "clusters": []interface{}{"cluster2"},
+ "names": []interface{}{"remote-index2"},
+ "privileges": []interface{}{"write"},
+ "query": `{"term": {"status": "active"}}`,
+ },
+ },
+ },
+ },
+ {
+ name: "all_empty_fields_removed_comprehensive",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": "",
+ "metadata": "",
+ "cluster": []string{"all"},
+ "indices": []interface{}{
+ map[string]interface{}{
+ "names": []string{"index1"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ },
+ "remote_indices": []interface{}{
+ map[string]interface{}{
+ "clusters": []string{"cluster1"},
+ "names": []string{"remote-index1"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "cluster": []interface{}{"all"},
+ "indices": []interface{}{
+ map[string]interface{}{
+ "names": []interface{}{"index1"},
+ "privileges": []interface{}{"read"},
+ },
+ },
+ "remote_indices": []interface{}{
+ map[string]interface{}{
+ "clusters": []interface{}{"cluster1"},
+ "names": []interface{}{"remote-index1"},
+ "privileges": []interface{}{"read"},
+ },
+ },
+ },
+ },
+ {
+ name: "no_indices_or_remote_indices",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": "",
+ "metadata": "",
+ "cluster": []string{"all"},
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "cluster": []interface{}{"all"},
+ },
+ },
+ {
+ name: "indices_not_array",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": "not-an-array",
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": "not-an-array", // Should be preserved as-is if not an array
+ },
+ },
+ {
+ name: "remote_indices_not_array",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": "not-an-array",
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": "not-an-array", // Should be preserved as-is if not an array
+ },
+ },
+ {
+ name: "index_item_not_map",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": []interface{}{
+ "not-a-map",
+ map[string]interface{}{
+ "names": []string{"index1"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "indices": []interface{}{
+ "not-a-map", // Should be preserved as-is if not a map
+ map[string]interface{}{
+ "names": []interface{}{"index1"},
+ "privileges": []interface{}{"read"},
+ },
+ },
+ },
+ },
+ {
+ name: "remote_index_item_not_map",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": []interface{}{
+ "not-a-map",
+ map[string]interface{}{
+ "clusters": []string{"cluster1"},
+ "names": []string{"remote-index1"},
+ "privileges": []string{"read"},
+ "query": "",
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "remote_indices": []interface{}{
+ "not-a-map", // Should be preserved as-is if not a map
+ map[string]interface{}{
+ "clusters": []interface{}{"cluster1"},
+ "names": []interface{}{"remote-index1"},
+ "privileges": []interface{}{"read"},
+ },
+ },
+ },
+ },
+ {
+ name: "nil_global_and_metadata_preserved",
+ input: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": nil,
+ "metadata": nil,
+ "cluster": []string{"all"},
+ },
+ expected: map[string]interface{}{
+ "name": "test-role",
+ "description": "Test role",
+ "global": nil,
+ "metadata": nil,
+ "cluster": []interface{}{"all"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Prepare the raw state JSON
+ inputJSON, err := json.Marshal(tt.input)
+ require.NoError(t, err)
+
+ // Create the request
+ req := resource.UpgradeStateRequest{
+ RawState: &tfprotov6.RawState{
+ JSON: inputJSON,
+ },
+ }
+
+ // Create the response
+ resp := &resource.UpgradeStateResponse{}
+
+ // Call the function
+ v0ToV1(context.Background(), req, resp)
+
+ if tt.expectError {
+ assert.True(t, resp.Diagnostics.HasError())
+ if tt.errorContains != "" {
+ found := false
+ for _, diag := range resp.Diagnostics.Errors() {
+ if assert.Contains(t, diag.Detail(), tt.errorContains) {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Expected error message not found")
+ }
+ return
+ }
+
+ // Should not have errors
+ assert.False(t, resp.Diagnostics.HasError(), "Unexpected errors: %v", resp.Diagnostics)
+
+ // Parse the output
+ require.NotNil(t, resp.DynamicValue)
+ require.NotNil(t, resp.DynamicValue.JSON)
+
+ var actualState map[string]interface{}
+ err = json.Unmarshal(resp.DynamicValue.JSON, &actualState)
+ require.NoError(t, err)
+
+ // Compare the results
+ assert.Equal(t, tt.expected, actualState)
+ })
+ }
+}
+
+func TestV0ToV1_InvalidJSON(t *testing.T) {
+ req := resource.UpgradeStateRequest{
+ RawState: &tfprotov6.RawState{
+ JSON: []byte("invalid json"),
+ },
+ }
+
+ resp := &resource.UpgradeStateResponse{}
+
+ v0ToV1(context.Background(), req, resp)
+
+ assert.True(t, resp.Diagnostics.HasError())
+ assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), "State Upgrade Error")
+ assert.Contains(t, resp.Diagnostics.Errors()[0].Detail(), "Could not unmarshal prior state")
+}
diff --git a/internal/elasticsearch/security/role/schema.go b/internal/elasticsearch/security/role/schema.go
new file mode 100644
index 000000000..2d6785bcf
--- /dev/null
+++ b/internal/elasticsearch/security/role/schema.go
@@ -0,0 +1,253 @@
+package role
+
+import (
+ "context"
+ _ "embed"
+
+ "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema"
+)
+
+const CurrentSchemaVersion = 1
+
+//go:embed resource-description.md
+var roleResourceDescription string
+
+func (r *roleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = GetSchema(CurrentSchemaVersion)
+}
+
+func GetSchema(version int64) schema.Schema {
+ return schema.Schema{
+ Version: version,
+ MarkdownDescription: roleResourceDescription,
+ Blocks: map[string]schema.Block{
+ "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false),
+ "applications": schema.SetNestedBlock{
+ MarkdownDescription: "A list of application privilege entries.",
+ NestedObject: schema.NestedBlockObject{
+ Attributes: map[string]schema.Attribute{
+ "application": schema.StringAttribute{
+ MarkdownDescription: "The name of the application to which this entry applies.",
+ Required: true,
+ },
+ "privileges": schema.SetAttribute{
+ MarkdownDescription: "A list of strings, where each element is the name of an application privilege or action.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "resources": schema.SetAttribute{
+ MarkdownDescription: "A list resources to which the privileges are applied.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ "indices": schema.SetNestedBlock{
+ MarkdownDescription: "A list of indices permissions entries.",
+ NestedObject: schema.NestedBlockObject{
+ Attributes: map[string]schema.Attribute{
+ "field_security": schema.ListNestedAttribute{
+ MarkdownDescription: "The document fields that the owners of the role have read access to.",
+ Optional: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "grant": schema.SetAttribute{
+ MarkdownDescription: "List of the fields to grant the access to.",
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ "except": schema.SetAttribute{
+ MarkdownDescription: "List of the fields to which the grants will not be applied.",
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ "names": schema.SetAttribute{
+ MarkdownDescription: "A list of indices (or index name patterns) to which the permissions in this entry apply.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "privileges": schema.SetAttribute{
+ MarkdownDescription: "The index level privileges that the owners of the role have on the specified indices.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "query": schema.StringAttribute{
+ MarkdownDescription: "A search query that defines the documents the owners of the role have read access to.",
+ Optional: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "allow_restricted_indices": schema.BoolAttribute{
+ MarkdownDescription: "Include matching restricted indices in names parameter. Usage is strongly discouraged as it can grant unrestricted operations on critical data, make the entire system unstable or leak sensitive information.",
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.Bool{
+ boolplanmodifier.UseStateForUnknown(),
+ },
+ },
+ },
+ },
+ },
+ "remote_indices": schema.SetNestedBlock{
+ MarkdownDescription: "A list of remote indices permissions entries. Remote indices are effective for remote clusters configured with the API key based model. They have no effect for remote clusters configured with the certificate based model.",
+ NestedObject: schema.NestedBlockObject{
+ Blocks: map[string]schema.Block{
+ "field_security": schema.ListNestedBlock{
+ MarkdownDescription: "The document fields that the owners of the role have read access to.",
+ NestedObject: schema.NestedBlockObject{
+ Attributes: map[string]schema.Attribute{
+ "grant": schema.SetAttribute{
+ MarkdownDescription: "List of the fields to grant the access to.",
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ "except": schema.SetAttribute{
+ MarkdownDescription: "List of the fields to which the grants will not be applied.",
+ Optional: true,
+ Computed: true,
+ ElementType: types.StringType,
+ PlanModifiers: []planmodifier.Set{
+ setplanmodifier.UseStateForUnknown(),
+ },
+ },
+ },
+ },
+ },
+ },
+ Attributes: map[string]schema.Attribute{
+ "clusters": schema.SetAttribute{
+ MarkdownDescription: "A list of cluster aliases to which the permissions in this entry apply.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "query": schema.StringAttribute{
+ MarkdownDescription: "A search query that defines the documents the owners of the role have read access to.",
+ Optional: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "names": schema.SetAttribute{
+ MarkdownDescription: "A list of indices (or index name patterns) to which the permissions in this entry apply.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "privileges": schema.SetAttribute{
+ MarkdownDescription: "The index level privileges that the owners of the role have on the specified indices.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ },
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: "Internal identifier of the resource",
+ Computed: true,
+ },
+ "name": schema.StringAttribute{
+ MarkdownDescription: "The name of the role.",
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "description": schema.StringAttribute{
+ MarkdownDescription: "The description of the role.",
+ Optional: true,
+ },
+ "global": schema.StringAttribute{
+ MarkdownDescription: "An object defining global privileges.",
+ Optional: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "cluster": schema.SetAttribute{
+ MarkdownDescription: "A list of cluster privileges. These privileges define the cluster level actions that users with this role are able to execute.",
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ "metadata": schema.StringAttribute{
+ MarkdownDescription: "Optional meta-data.",
+ Optional: true,
+ Computed: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "run_as": schema.SetAttribute{
+ MarkdownDescription: "A list of users that the owners of this role can impersonate.",
+ Optional: true,
+ ElementType: types.StringType,
+ },
+ },
+ }
+}
+
+// Helper functions to get attribute types from the schema
+func getApplicationAttrTypes() map[string]attr.Type {
+ attrs := GetSchema(CurrentSchemaVersion).Blocks["applications"].(schema.SetNestedBlock).NestedObject.Attributes
+ result := make(map[string]attr.Type)
+ for name, attr := range attrs {
+ result[name] = attr.GetType()
+ }
+ return result
+}
+
+func getFieldSecurityAttrTypes() map[string]attr.Type {
+ attrs := GetSchema(CurrentSchemaVersion).Blocks["indices"].(schema.SetNestedBlock).NestedObject.Attributes["field_security"].(schema.ListNestedAttribute).NestedObject.Attributes
+ result := make(map[string]attr.Type)
+ for name, attr := range attrs {
+ result[name] = attr.GetType()
+ }
+ return result
+}
+
+func getIndexPermsAttrTypes() map[string]attr.Type {
+ nestedObj := GetSchema(CurrentSchemaVersion).Blocks["indices"].(schema.SetNestedBlock).NestedObject
+ result := make(map[string]attr.Type)
+ for name, attr := range nestedObj.Attributes {
+ result[name] = attr.GetType()
+ }
+ return result
+}
+
+func getRemoteIndexPermsAttrTypes() map[string]attr.Type {
+ nestedObj := GetSchema(CurrentSchemaVersion).Blocks["remote_indices"].(schema.SetNestedBlock).NestedObject
+ result := make(map[string]attr.Type)
+ // Add attributes
+ for name, attr := range nestedObj.Attributes {
+ result[name] = attr.GetType()
+ }
+ // Add blocks as attributes (field_security is a block in remote_indices)
+ for name, block := range nestedObj.Blocks {
+ switch b := block.(type) {
+ case schema.ListNestedBlock:
+ // For ListNestedBlock, the type is ListType with ObjectType element
+ blockAttrs := make(map[string]attr.Type)
+ for attrName, attr := range b.NestedObject.Attributes {
+ blockAttrs[attrName] = attr.GetType()
+ }
+ result[name] = types.ListType{ElemType: types.ObjectType{AttrTypes: blockAttrs}}
+ }
+ }
+ return result
+}
+
+func getRemoteFieldSecurityAttrTypes() map[string]attr.Type {
+ attrs := GetSchema(CurrentSchemaVersion).Blocks["remote_indices"].(schema.SetNestedBlock).NestedObject.Blocks["field_security"].(schema.ListNestedBlock).NestedObject.Attributes
+ result := make(map[string]attr.Type)
+ for name, attr := range attrs {
+ result[name] = attr.GetType()
+ }
+ return result
+}
diff --git a/internal/elasticsearch/security/role/update.go b/internal/elasticsearch/security/role/update.go
new file mode 100644
index 000000000..78c7140c8
--- /dev/null
+++ b/internal/elasticsearch/security/role/update.go
@@ -0,0 +1,99 @@
+package role
+
+import (
+ "context"
+ "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/diagutil"
+ "github.com/elastic/terraform-provider-elasticstack/internal/utils"
+ "github.com/hashicorp/go-version"
+ "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"
+)
+
+var (
+ MinSupportedRemoteIndicesVersion = version.Must(version.NewVersion("8.10.0"))
+ MinSupportedDescriptionVersion = version.Must(version.NewVersion("8.15.0"))
+)
+
+func (r *roleResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics {
+ var data RoleData
+ var diags diag.Diagnostics
+ diags.Append(plan.Get(ctx, &data)...)
+ if diags.HasError() {
+ return diags
+ }
+
+ roleId := data.Name.ValueString()
+ id, sdkDiags := r.client.ID(ctx, roleId)
+ diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...)
+ if diags.HasError() {
+ return diags
+ }
+
+ client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client)
+ diags.Append(diags...)
+ if diags.HasError() {
+ return diags
+ }
+
+ serverVersion, sdkDiags := client.ServerVersion(ctx)
+ diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...)
+ if diags.HasError() {
+ return diags
+ }
+
+ // Check version requirements
+ if utils.IsKnown(data.Description) {
+ if serverVersion.LessThan(MinSupportedDescriptionVersion) {
+ diags.AddError("Unsupported Feature", fmt.Sprintf("'description' is supported only for Elasticsearch v%s and above", MinSupportedDescriptionVersion.String()))
+ return diags
+ }
+ }
+
+ if utils.IsKnown(data.RemoteIndices) {
+ var remoteIndicesList []RemoteIndexPermsData
+ diags.Append(data.RemoteIndices.ElementsAs(ctx, &remoteIndicesList, false)...)
+ if len(remoteIndicesList) > 0 && serverVersion.LessThan(MinSupportedRemoteIndicesVersion) {
+ diags.AddError("Unsupported Feature", fmt.Sprintf("'remote_indices' is supported only for Elasticsearch v%s and above", MinSupportedRemoteIndicesVersion.String()))
+ return diags
+ }
+ }
+
+ // Convert to API model
+ role, diags := data.toAPIModel(ctx)
+ if diags.HasError() {
+ return diags
+ }
+
+ // Put the role
+ sdkDiags = elasticsearch.PutRole(ctx, client, role)
+ diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...)
+ if diags.HasError() {
+ return diags
+ }
+
+ data.Id = types.StringValue(id.String())
+ readData, readDiags := r.read(ctx, data)
+ diags.Append(readDiags...)
+ if diags.HasError() {
+ return diags
+ }
+
+ if readData == nil {
+ diags.AddError("Not Found", fmt.Sprintf("Role %q was not found after update", roleId))
+ return diags
+ }
+
+ diags.Append(state.Set(ctx, readData)...)
+ return diags
+}
+
+func (r *roleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ diags := r.update(ctx, req.Plan, &resp.State)
+ resp.Diagnostics.Append(diags...)
+}
diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go
index 8487b6bd8..63d2c1573 100644
--- a/provider/plugin_framework.go
+++ b/provider/plugin_framework.go
@@ -12,6 +12,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"
"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"
@@ -114,6 +115,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource {
output.NewResource,
server_host.NewResource,
system_user.NewSystemUserResource,
+ role.NewRoleResource,
script.NewScriptResource,
maintenance_window.NewResource,
enrich.NewEnrichPolicyResource,
diff --git a/provider/provider.go b/provider/provider.go
index d0a177b56..861fa16d9 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -88,7 +88,6 @@ func New(version string) *schema.Provider {
"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(),