From 1a7e65e07fbfbb454e0a25023af74f298752d937 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Wed, 26 Nov 2025 12:39:15 -0700 Subject: [PATCH 1/4] Add security list item resource --- .../kibana/security_list_item/acc_test.go | 73 +++++++++++ internal/kibana/security_list_item/create.go | 77 ++++++++++++ internal/kibana/security_list_item/delete.go | 33 +++++ internal/kibana/security_list_item/models.go | 113 ++++++++++++++++++ internal/kibana/security_list_item/read.go | 59 +++++++++ .../resource-description.md | 57 +++++++++ .../kibana/security_list_item/resource.go | 38 ++++++ internal/kibana/security_list_item/schema.go | 76 ++++++++++++ .../create/main.tf | 16 +++ .../update/main.tf | 16 +++ internal/kibana/security_list_item/update.go | 77 ++++++++++++ 11 files changed, 635 insertions(+) create mode 100644 internal/kibana/security_list_item/acc_test.go create mode 100644 internal/kibana/security_list_item/create.go create mode 100644 internal/kibana/security_list_item/delete.go create mode 100644 internal/kibana/security_list_item/models.go create mode 100644 internal/kibana/security_list_item/read.go create mode 100644 internal/kibana/security_list_item/resource-description.md create mode 100644 internal/kibana/security_list_item/resource.go create mode 100644 internal/kibana/security_list_item/schema.go create mode 100644 internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/create/main.tf create mode 100644 internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/update/main.tf create mode 100644 internal/kibana/security_list_item/update.go diff --git a/internal/kibana/security_list_item/acc_test.go b/internal/kibana/security_list_item/acc_test.go new file mode 100644 index 000000000..34168a453 --- /dev/null +++ b/internal/kibana/security_list_item/acc_test.go @@ -0,0 +1,73 @@ +package security_list_item_test + +import ( + "context" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func ensureListIndexExists(t *testing.T) { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + kibanaClient, err := client.GetKibanaOapiClient() + if err != nil { + t.Fatalf("Failed to get Kibana client: %v", err) + } + + diags := kibana_oapi.CreateListIndex(context.Background(), kibanaClient, "default") + if diags.HasError() { + // It's OK if it already exists, we'll only fail on other errors + for _, d := range diags { + if d.Summary() != "Unexpected status code from server: got HTTP 409" { + t.Fatalf("Failed to create list index: %v", d.Detail()) + } + } + } +} + +func TestAccResourceSecurityListItem(t *testing.T) { + listID := "test-list-items-" + uuid.New().String() + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + ensureListIndexExists(t) + }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { // Create + ConfigDirectory: acctest.NamedTestCaseDirectory("create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "value": config.StringVariable("test-value-1"), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list_item.test", "id"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_list_item.test", "value", "test-value-1"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list_item.test", "created_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list_item.test", "created_by"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list_item.test", "updated_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list_item.test", "updated_by"), + ), + }, + { // Update + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "value": config.StringVariable("test-value-updated"), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_list_item.test", "value", "test-value-updated"), + ), + }, + }, + }) +} diff --git a/internal/kibana/security_list_item/create.go b/internal/kibana/security_list_item/create.go new file mode 100644 index 000000000..f9ae9916a --- /dev/null +++ b/internal/kibana/security_list_item/create.go @@ -0,0 +1,77 @@ +package security_list_item + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListItemResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan SecurityListItemModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Convert plan to API request + createReq, diags := plan.toAPICreateModel(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Create the list item + createResp, diags := kibana_oapi.CreateListItem(ctx, client, plan.SpaceID.ValueString(), *createReq) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil || createResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to create security list item", "API returned empty response") + return + } + + // Read the created list item to populate state + id := kbapi.SecurityListsAPIListId(createResp.JSON200.Id) + readParams := &kbapi.ReadListItemParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Unmarshal the response body to get the list item + var listItem kbapi.SecurityListsAPIListItem + if err := json.Unmarshal(readResp.Body, &listItem); err != nil { + resp.Diagnostics.AddError("Failed to parse list item response", err.Error()) + return + } + + // Update state with read response + diags = plan.fromAPIModel(ctx, &listItem) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} diff --git a/internal/kibana/security_list_item/delete.go b/internal/kibana/security_list_item/delete.go new file mode 100644 index 000000000..a28a499eb --- /dev/null +++ b/internal/kibana/security_list_item/delete.go @@ -0,0 +1,33 @@ +package security_list_item + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListItemResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state SecurityListItemModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Delete by ID + id := kbapi.SecurityListsAPIListItemId(state.ID.ValueString()) + params := &kbapi.DeleteListItemParams{ + Id: &id, + } + + diags := kibana_oapi.DeleteListItem(ctx, client, state.SpaceID.ValueString(), params) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security_list_item/models.go b/internal/kibana/security_list_item/models.go new file mode 100644 index 000000000..cf2438f2b --- /dev/null +++ b/internal/kibana/security_list_item/models.go @@ -0,0 +1,113 @@ +package security_list_item + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type SecurityListItemModel struct { + ID types.String `tfsdk:"id"` + SpaceID types.String `tfsdk:"space_id"` + ListID types.String `tfsdk:"list_id"` + Value types.String `tfsdk:"value"` + Meta types.String `tfsdk:"meta"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` + Version types.String `tfsdk:"version"` +} + +// toAPICreateModel converts the Terraform model to the API create request body +func (m *SecurityListItemModel) toAPICreateModel(ctx context.Context) (*kbapi.CreateListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + body := &kbapi.CreateListItemJSONRequestBody{ + ListId: kbapi.SecurityListsAPIListId(m.ListID.ValueString()), + Value: kbapi.SecurityListsAPIListItemValue(m.Value.ValueString()), + } + + // Set optional ID if specified + if !m.ID.IsNull() && !m.ID.IsUnknown() { + id := kbapi.SecurityListsAPIListItemId(m.ID.ValueString()) + body.Id = &id + } + + // Set optional meta if specified + if !m.Meta.IsNull() && !m.Meta.IsUnknown() { + var meta kbapi.SecurityListsAPIListItemMetadata + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { + diags.AddError("Failed to parse meta JSON", err.Error()) + return nil, diags + } + body.Meta = &meta + } + + return body, diags +} + +// toAPIUpdateModel converts the Terraform model to the API update request body +func (m *SecurityListItemModel) toAPIUpdateModel(ctx context.Context) (*kbapi.UpdateListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + body := &kbapi.UpdateListItemJSONRequestBody{ + Id: kbapi.SecurityListsAPIListItemId(m.ID.ValueString()), + Value: kbapi.SecurityListsAPIListItemValue(m.Value.ValueString()), + } + + // Set optional version if available + if !m.Version.IsNull() && !m.Version.IsUnknown() { + version := kbapi.SecurityListsAPIListVersionId(m.Version.ValueString()) + body.UnderscoreVersion = &version + } + + // Set optional meta if specified + if !m.Meta.IsNull() && !m.Meta.IsUnknown() { + var meta kbapi.SecurityListsAPIListItemMetadata + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { + diags.AddError("Failed to parse meta JSON", err.Error()) + return nil, diags + } + body.Meta = &meta + } + + return body, diags +} + +// fromAPIModel populates the Terraform model from an API response +func (m *SecurityListItemModel) fromAPIModel(ctx context.Context, apiItem *kbapi.SecurityListsAPIListItem) diag.Diagnostics { + var diags diag.Diagnostics + + m.ID = types.StringValue(string(apiItem.Id)) + m.ListID = types.StringValue(string(apiItem.ListId)) + m.Value = types.StringValue(string(apiItem.Value)) + m.CreatedAt = types.StringValue(apiItem.CreatedAt.Format("2006-01-02T15:04:05.000Z")) + m.CreatedBy = types.StringValue(apiItem.CreatedBy) + m.UpdatedAt = types.StringValue(apiItem.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) + m.UpdatedBy = types.StringValue(apiItem.UpdatedBy) + + // Set version if available + if apiItem.UnderscoreVersion != nil { + m.Version = types.StringValue(string(*apiItem.UnderscoreVersion)) + } else { + m.Version = types.StringNull() + } + + // Set meta if available + if apiItem.Meta != nil { + metaJSON, err := json.Marshal(apiItem.Meta) + if err != nil { + diags.AddError("Failed to serialize meta", err.Error()) + return diags + } + m.Meta = types.StringValue(string(metaJSON)) + } else { + m.Meta = types.StringNull() + } + + return diags +} diff --git a/internal/kibana/security_list_item/read.go b/internal/kibana/security_list_item/read.go new file mode 100644 index 000000000..c995d9ccd --- /dev/null +++ b/internal/kibana/security_list_item/read.go @@ -0,0 +1,59 @@ +package security_list_item + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListItemResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state SecurityListItemModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Read by ID + id := kbapi.SecurityListsAPIListId(state.ID.ValueString()) + params := &kbapi.ReadListItemParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetListItem(ctx, client, state.SpaceID.ValueString(), params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // The response can be a single item or an array, so we need to unmarshal from the body + // When querying by ID, we expect a single item + var listItem kbapi.SecurityListsAPIListItem + if err := json.Unmarshal(readResp.Body, &listItem); err != nil { + resp.Diagnostics.AddError("Failed to parse list item response", err.Error()) + return + } + + // Update state with response + diags = state.fromAPIModel(ctx, &listItem) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} diff --git a/internal/kibana/security_list_item/resource-description.md b/internal/kibana/security_list_item/resource-description.md new file mode 100644 index 000000000..d00e14915 --- /dev/null +++ b/internal/kibana/security_list_item/resource-description.md @@ -0,0 +1,57 @@ +--- +subcategory: "Kibana" +layout: "" +page_title: "Elasticstack: elasticstack_kibana_security_list_item Resource" +description: |- + Manages items within Kibana security value lists. +--- + +# Resource: elasticstack_kibana_security_list_item + +Manages items within Kibana security value lists. Value lists are containers for values that can be used within exception lists to define conditions. This resource allows you to add, update, and remove individual values (items) in those lists. + +Value list items are used to store data values that match the type of their parent security list (e.g., IP addresses, keywords, etc.). These items can then be referenced in exception list entries to define exception conditions. + +## Example Usage + +```terraform +# First create a security list +resource "elasticstack_kibana_security_list" "ip_list" { + list_id = "allowed_ips" + name = "Allowed IP Addresses" + description = "List of IP addresses that are allowed" + type = "ip" +} + +# Add an IP address to the list +resource "elasticstack_kibana_security_list_item" "ip_item_1" { + list_id = elasticstack_kibana_security_list.ip_list.list_id + value = "192.168.1.1" +} + +# Add another IP address +resource "elasticstack_kibana_security_list_item" "ip_item_2" { + list_id = elasticstack_kibana_security_list.ip_list.list_id + value = "10.0.0.1" +} + +# Add a keyword item with metadata +resource "elasticstack_kibana_security_list" "keyword_list" { + list_id = "allowed_domains" + name = "Allowed Domains" + description = "List of domains that are allowed" + type = "keyword" +} + +resource "elasticstack_kibana_security_list_item" "domain_item" { + list_id = elasticstack_kibana_security_list.keyword_list.list_id + value = "example.com" + meta = jsonencode({ + note = "Primary corporate domain" + }) +} +``` + +## Note on Space Support + +**Important**: The generated Kibana API client does not currently support space_id for list item operations. While the `space_id` attribute is available in the schema for future compatibility, list items currently operate in the default space only. This is a known limitation that will be addressed in a future update when the API client is regenerated with proper space support. diff --git a/internal/kibana/security_list_item/resource.go b/internal/kibana/security_list_item/resource.go new file mode 100644 index 000000000..0f9402be3 --- /dev/null +++ b/internal/kibana/security_list_item/resource.go @@ -0,0 +1,38 @@ +package security_list_item + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// Ensure provider defined types fully satisfy framework interfaces +var ( + _ resource.Resource = &securityListItemResource{} + _ resource.ResourceWithConfigure = &securityListItemResource{} + _ resource.ResourceWithImportState = &securityListItemResource{} +) + +func NewResource() resource.Resource { + return &securityListItemResource{} +} + +type securityListItemResource struct { + client *clients.ApiClient +} + +func (r *securityListItemResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_kibana_security_list_item" +} + +func (r *securityListItemResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +func (r *securityListItemResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), request, response) +} diff --git a/internal/kibana/security_list_item/schema.go b/internal/kibana/security_list_item/schema.go new file mode 100644 index 000000000..3315e715f --- /dev/null +++ b/internal/kibana/security_list_item/schema.go @@ -0,0 +1,76 @@ +package security_list_item + +import ( + "context" + _ "embed" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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" +) + +//go:embed resource-description.md +var securityListItemResourceDescription string + +func (r *securityListItemResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: securityListItemResourceDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The value list item's identifier (auto-generated by Kibana if not specified).", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "space_id": schema.StringAttribute{ + MarkdownDescription: "An identifier for the space. If space_id is not provided, the default space is used.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "list_id": schema.StringAttribute{ + MarkdownDescription: "The value list's identifier that this item belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "value": schema.StringAttribute{ + MarkdownDescription: "The value used to evaluate exceptions. The value's data type must match the list's type.", + Required: true, + }, + "meta": schema.StringAttribute{ + MarkdownDescription: "Placeholder for metadata about the value list item as JSON string.", + Optional: true, + }, + "version": schema.StringAttribute{ + MarkdownDescription: "The version id, normally returned by the API when the document is retrieved. Used to ensure updates are done against the latest version.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the list item was created.", + Computed: true, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The user who created the list item.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the list item was last updated.", + Computed: true, + }, + "updated_by": schema.StringAttribute{ + MarkdownDescription: "The user who last updated the list item.", + Computed: true, + }, + }, + } +} diff --git a/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/create/main.tf b/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/create/main.tf new file mode 100644 index 000000000..246c973a6 --- /dev/null +++ b/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/create/main.tf @@ -0,0 +1,16 @@ +variable "list_id" {} +variable "value" {} + +# First create a security list to put items in +resource "elasticstack_kibana_security_list" "test" { + list_id = var.list_id + name = "Test List for Items" + description = "A test security list for IP addresses" + type = "keyword" +} + +# Create a list item +resource "elasticstack_kibana_security_list_item" "test" { + list_id = elasticstack_kibana_security_list.test.list_id + value = var.value +} diff --git a/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/update/main.tf b/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/update/main.tf new file mode 100644 index 000000000..df1d6c668 --- /dev/null +++ b/internal/kibana/security_list_item/testdata/TestAccResourceSecurityListItem/update/main.tf @@ -0,0 +1,16 @@ +variable "list_id" {} +variable "value" {} + +# First create a security list to put items in +resource "elasticstack_kibana_security_list" "test" { + list_id = var.list_id + name = "Test List for Items" + description = "A test security list for IP addresses" + type = "keyword" +} + +# Create a list item with updated value +resource "elasticstack_kibana_security_list_item" "test" { + list_id = elasticstack_kibana_security_list.test.list_id + value = var.value +} diff --git a/internal/kibana/security_list_item/update.go b/internal/kibana/security_list_item/update.go new file mode 100644 index 000000000..b76e12906 --- /dev/null +++ b/internal/kibana/security_list_item/update.go @@ -0,0 +1,77 @@ +package security_list_item + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListItemResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan SecurityListItemModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Convert plan to API request + updateReq, diags := plan.toAPIUpdateModel(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Update the list item + updateResp, diags := kibana_oapi.UpdateListItem(ctx, client, plan.SpaceID.ValueString(), *updateReq) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil || updateResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to update security list item", "API returned empty response") + return + } + + // Read the updated list item to populate state + id := kbapi.SecurityListsAPIListId(updateResp.JSON200.Id) + readParams := &kbapi.ReadListItemParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Unmarshal the response body to get the list item + var listItem kbapi.SecurityListsAPIListItem + if err := json.Unmarshal(readResp.Body, &listItem); err != nil { + resp.Diagnostics.AddError("Failed to parse list item response", err.Error()) + return + } + + // Update state with read response + diags = plan.fromAPIModel(ctx, &listItem) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} From e14a45af7770fd37fe47474ece750881320da024 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Wed, 26 Nov 2025 12:39:29 -0700 Subject: [PATCH 2/4] Add security list item examples --- .../resource.tf | 13 +++++++++++++ .../resource_ip.tf | 13 +++++++++++++ .../resource_with_meta.tf | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 examples/resources/elasticstack_kibana_security_list_item/resource.tf create mode 100644 examples/resources/elasticstack_kibana_security_list_item/resource_ip.tf create mode 100644 examples/resources/elasticstack_kibana_security_list_item/resource_with_meta.tf diff --git a/examples/resources/elasticstack_kibana_security_list_item/resource.tf b/examples/resources/elasticstack_kibana_security_list_item/resource.tf new file mode 100644 index 000000000..705311869 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_list_item/resource.tf @@ -0,0 +1,13 @@ +# First create a security list +resource "elasticstack_kibana_security_list" "my_list" { + list_id = "allowed_domains" + name = "Allowed Domains" + description = "List of allowed domains" + type = "keyword" +} + +# Add an item to the list +resource "elasticstack_kibana_security_list_item" "domain_example" { + list_id = elasticstack_kibana_security_list.my_list.list_id + value = "example.com" +} diff --git a/examples/resources/elasticstack_kibana_security_list_item/resource_ip.tf b/examples/resources/elasticstack_kibana_security_list_item/resource_ip.tf new file mode 100644 index 000000000..d306ef013 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_list_item/resource_ip.tf @@ -0,0 +1,13 @@ +# First create an IP address list +resource "elasticstack_kibana_security_list" "ip_list" { + list_id = "allowed_ips" + name = "Allowed IP Addresses" + description = "List of allowed IP addresses" + type = "ip" +} + +# Add an IP address to the list +resource "elasticstack_kibana_security_list_item" "ip_example" { + list_id = elasticstack_kibana_security_list.ip_list.list_id + value = "192.168.1.1" +} diff --git a/examples/resources/elasticstack_kibana_security_list_item/resource_with_meta.tf b/examples/resources/elasticstack_kibana_security_list_item/resource_with_meta.tf new file mode 100644 index 000000000..03131f882 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_list_item/resource_with_meta.tf @@ -0,0 +1,18 @@ +# First create a security list +resource "elasticstack_kibana_security_list" "tagged_domains" { + list_id = "tagged_domains" + name = "Tagged Domains" + description = "Domains with associated metadata" + type = "keyword" +} + +# Add an item with metadata +resource "elasticstack_kibana_security_list_item" "domain_with_meta" { + list_id = elasticstack_kibana_security_list.tagged_domains.list_id + value = "internal.example.com" + meta = jsonencode({ + category = "internal" + owner = "infrastructure-team" + note = "Primary internal domain" + }) +} From 437ba8e1425b1e4f4483aad183a814b7d115fe26 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Wed, 26 Nov 2025 12:53:11 -0700 Subject: [PATCH 3/4] Add security list item as experimental provider --- provider/plugin_framework.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 7d237654b..111c128f0 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -32,6 +32,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/maintenance_window" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_detection_rule" + "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_list_item" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/spaces" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics/monitor" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics/parameter" @@ -145,7 +146,9 @@ func (p *Provider) resources(ctx context.Context) []func() resource.Resource { } func (p *Provider) experimentalResources(ctx context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{ + security_list_item.NewResource, + } } func (p *Provider) dataSources(ctx context.Context) []func() datasource.DataSource { From cb6e1714d63b503eb9f94d56858f4573cf3a6761 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Wed, 26 Nov 2025 13:16:05 -0700 Subject: [PATCH 4/4] Add security list --- docs/resources/kibana_security_list.md | 110 +++++++++++++ internal/kibana/security_list/acc_test.go | 105 ++++++++++++ internal/kibana/security_list/create.go | 69 ++++++++ internal/kibana/security_list/delete.go | 33 ++++ internal/kibana/security_list/models.go | 154 ++++++++++++++++++ internal/kibana/security_list/read.go | 50 ++++++ .../security_list/resource-description.md | 27 +++ internal/kibana/security_list/resource.go | 38 +++++ internal/kibana/security_list/schema.go | 122 ++++++++++++++ .../create/main.tf | 22 +++ .../update/main.tf | 22 +++ .../keyword_type/main.tf | 22 +++ internal/kibana/security_list/update.go | 80 +++++++++ provider/plugin_framework.go | 2 + 14 files changed, 856 insertions(+) create mode 100644 docs/resources/kibana_security_list.md create mode 100644 internal/kibana/security_list/acc_test.go create mode 100644 internal/kibana/security_list/create.go create mode 100644 internal/kibana/security_list/delete.go create mode 100644 internal/kibana/security_list/models.go create mode 100644 internal/kibana/security_list/read.go create mode 100644 internal/kibana/security_list/resource-description.md create mode 100644 internal/kibana/security_list/resource.go create mode 100644 internal/kibana/security_list/schema.go create mode 100644 internal/kibana/security_list/testdata/TestAccResourceSecurityList/create/main.tf create mode 100644 internal/kibana/security_list/testdata/TestAccResourceSecurityList/update/main.tf create mode 100644 internal/kibana/security_list/testdata/TestAccResourceSecurityList_KeywordType/keyword_type/main.tf create mode 100644 internal/kibana/security_list/update.go diff --git a/docs/resources/kibana_security_list.md b/docs/resources/kibana_security_list.md new file mode 100644 index 000000000..52299ff2f --- /dev/null +++ b/docs/resources/kibana_security_list.md @@ -0,0 +1,110 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "elasticstack_kibana_security_list Resource - terraform-provider-elasticstack" +subcategory: "Kibana" +description: |- + Manages Kibana security lists (also known as value lists). Security lists are used by exception items to define sets of values for matching or excluding in security rules. + Example Usage + + resource "elasticstack_kibana_security_list" "ip_list" { + space_id = "default" + name = "Trusted IP Addresses" + description = "List of trusted IP addresses for security rules" + type = "ip" + } + + resource "elasticstack_kibana_security_list" "keyword_list" { + space_id = "security" + list_id = "custom-keywords" + name = "Custom Keywords" + description = "Custom keyword list for detection rules" + type = "keyword" + } + + Notes + Security lists define the type of data they can contain via the type attributeOnce created, the type of a list cannot be changedLists can be referenced by exception items to create more sophisticated matching rulesThe list_id is auto-generated if not provided +--- + +# elasticstack_kibana_security_list (Resource) + +Manages Kibana security lists (also known as value lists). Security lists are used by exception items to define sets of values for matching or excluding in security rules. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_list" "ip_list" { + space_id = "default" + name = "Trusted IP Addresses" + description = "List of trusted IP addresses for security rules" + type = "ip" +} + +resource "elasticstack_kibana_security_list" "keyword_list" { + space_id = "security" + list_id = "custom-keywords" + name = "Custom Keywords" + description = "Custom keyword list for detection rules" + type = "keyword" +} +``` + +## Notes + +- Security lists define the type of data they can contain via the `type` attribute +- Once created, the `type` of a list cannot be changed +- Lists can be referenced by exception items to create more sophisticated matching rules +- The `list_id` is auto-generated if not provided + +## Example Usage + +### IP address list + +```terraform +resource "elasticstack_kibana_security_list" "ip_list" { + space_id = "default" + name = "Trusted IP Addresses" + description = "List of trusted IP addresses for security rules" + type = "ip" +} +``` + +### Keyword list with custom list_id + +```terraform +resource "elasticstack_kibana_security_list" "keyword_list" { + space_id = "security" + list_id = "custom-keywords" + name = "Custom Keywords" + description = "Custom keyword list for detection rules" + type = "keyword" +} +``` + + +## Schema + +### Required + +- `description` (String) Describes the security list. +- `name` (String) The name of the security list. +- `type` (String) Specifies the Elasticsearch data type of values the list contains. Valid values include: `binary`, `boolean`, `byte`, `date`, `date_nanos`, `date_range`, `double`, `double_range`, `float`, `float_range`, `geo_point`, `geo_shape`, `half_float`, `integer`, `integer_range`, `ip`, `ip_range`, `keyword`, `long`, `long_range`, `shape`, `short`, `text`. + +### Optional + +- `deserializer` (String) Determines how retrieved list item values are presented. By default, list items are presented using Handlebars expressions based on the type. +- `id` (String) The unique identifier of the security list (auto-generated by Kibana if not specified). +- `list_id` (String) The value list's human-readable identifier. +- `meta` (String) Placeholder for metadata about the value list as JSON string. +- `serializer` (String) Determines how uploaded list item values are parsed. By default, list items are parsed using named regex groups based on the type. +- `space_id` (String) An identifier for the space. If space_id is not provided, the default space is used. +- `version` (Number) The document version number. + +### Read-Only + +- `created_at` (String) The timestamp of when the list was created. +- `created_by` (String) The user who created the list. +- `immutable` (Boolean) Whether the list is immutable. +- `tie_breaker_id` (String) Field used in search to ensure all containers are sorted and returned correctly. +- `updated_at` (String) The timestamp of when the list was last updated. +- `updated_by` (String) The user who last updated the list. +- `version_id` (String) The version id, normally returned by the API when the document is retrieved. diff --git a/internal/kibana/security_list/acc_test.go b/internal/kibana/security_list/acc_test.go new file mode 100644 index 000000000..e9dc7c1d7 --- /dev/null +++ b/internal/kibana/security_list/acc_test.go @@ -0,0 +1,105 @@ +package security_list_test + +import ( + "context" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func ensureListIndexExists(t *testing.T) { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + kibanaClient, err := client.GetKibanaOapiClient() + if err != nil { + t.Fatalf("Failed to get Kibana client: %v", err) + } + + diags := kibana_oapi.CreateListIndex(context.Background(), kibanaClient, "default") + if diags.HasError() { + // It's OK if it already exists, we'll only fail on other errors + for _, d := range diags { + if d.Summary() != "Unexpected status code from server: got HTTP 409" { + t.Fatalf("Failed to create list index: %v", d.Detail()) + } + } + } +} + +func TestAccResourceSecurityList(t *testing.T) { + listID := "test-list-" + uuid.New().String() + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + ensureListIndexExists(t) + }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { // Create + ConfigDirectory: acctest.NamedTestCaseDirectory("create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "name": config.StringVariable("Test Security List"), + "description": config.StringVariable("A test security list for IP addresses"), + "type": config.StringVariable("ip"), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list.test", "id"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "name", "Test Security List"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "description", "A test security list for IP addresses"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "type", "ip"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list.test", "created_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list.test", "created_by"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list.test", "updated_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_list.test", "updated_by"), + ), + }, + { // Update + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "name": config.StringVariable("Updated Security List"), + "description": config.StringVariable("An updated test security list"), + "type": config.StringVariable("ip"), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "name", "Updated Security List"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "description", "An updated test security list"), + ), + }, + }, + }) +} + +func TestAccResourceSecurityList_KeywordType(t *testing.T) { + listID := "keyword-list-" + uuid.New().String() + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + ensureListIndexExists(t) + }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + ConfigDirectory: acctest.NamedTestCaseDirectory("keyword_type"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "name": config.StringVariable("Keyword Security List"), + "description": config.StringVariable("A test security list for keywords"), + "type": config.StringVariable("keyword"), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_list.test", "type", "keyword"), + ), + }, + }, + }) +} diff --git a/internal/kibana/security_list/create.go b/internal/kibana/security_list/create.go new file mode 100644 index 000000000..969e09567 --- /dev/null +++ b/internal/kibana/security_list/create.go @@ -0,0 +1,69 @@ +package security_list + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan SecurityListModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Convert plan to API request + createReq, diags := plan.toCreateRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Create the list + spaceID := plan.SpaceID.ValueString() + createResp, diags := kibana_oapi.CreateList(ctx, client, spaceID, *createReq) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil || createResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to create security list", "API returned empty response") + return + } + + // Read the created list to populate state + readParams := &kbapi.ReadListParams{ + Id: createResp.JSON200.Id, + } + + readResp, diags := kibana_oapi.GetList(ctx, client, spaceID, readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response + diags = plan.fromAPI(ctx, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} diff --git a/internal/kibana/security_list/delete.go b/internal/kibana/security_list/delete.go new file mode 100644 index 000000000..a532b38f3 --- /dev/null +++ b/internal/kibana/security_list/delete.go @@ -0,0 +1,33 @@ +package security_list + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state SecurityListModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + spaceID := state.SpaceID.ValueString() + listID := state.ListID.ValueString() + + params := &kbapi.DeleteListParams{ + Id: kbapi.SecurityListsAPIListId(listID), + } + + diags := kibana_oapi.DeleteList(ctx, client, spaceID, params) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security_list/models.go b/internal/kibana/security_list/models.go new file mode 100644 index 000000000..f2524bd3c --- /dev/null +++ b/internal/kibana/security_list/models.go @@ -0,0 +1,154 @@ +package security_list + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type SecurityListModel struct { + ID types.String `tfsdk:"id"` + SpaceID types.String `tfsdk:"space_id"` + ListID types.String `tfsdk:"list_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` + Deserializer types.String `tfsdk:"deserializer"` + Serializer types.String `tfsdk:"serializer"` + Meta types.String `tfsdk:"meta"` + Version types.Int64 `tfsdk:"version"` + VersionID types.String `tfsdk:"version_id"` + Immutable types.Bool `tfsdk:"immutable"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` + TieBreakerID types.String `tfsdk:"tie_breaker_id"` +} + +// toCreateRequest converts the Terraform model to API create request +func (m *SecurityListModel) toCreateRequest() (*kbapi.CreateListJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + req := &kbapi.CreateListJSONRequestBody{ + Name: kbapi.SecurityListsAPIListName(m.Name.ValueString()), + Description: kbapi.SecurityListsAPIListDescription(m.Description.ValueString()), + Type: kbapi.SecurityListsAPIListType(m.Type.ValueString()), + } + + // Set optional fields + if !m.ListID.IsNull() && !m.ListID.IsUnknown() { + id := kbapi.SecurityListsAPIListId(m.ListID.ValueString()) + req.Id = &id + } + + if !m.Deserializer.IsNull() && !m.Deserializer.IsUnknown() { + deserializer := kbapi.SecurityListsAPIListDeserializer(m.Deserializer.ValueString()) + req.Deserializer = &deserializer + } + + if !m.Serializer.IsNull() && !m.Serializer.IsUnknown() { + serializer := kbapi.SecurityListsAPIListSerializer(m.Serializer.ValueString()) + req.Serializer = &serializer + } + + if !m.Meta.IsNull() && !m.Meta.IsUnknown() { + var metaMap kbapi.SecurityListsAPIListMetadata + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &metaMap); err != nil { + diags.AddError("Invalid meta JSON", err.Error()) + return nil, diags + } + req.Meta = &metaMap + } + + if !m.Version.IsNull() && !m.Version.IsUnknown() { + version := int(m.Version.ValueInt64()) + req.Version = &version + } + + return req, diags +} + +// toUpdateRequest converts the Terraform model to API update request +func (m *SecurityListModel) toUpdateRequest() (*kbapi.UpdateListJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + req := &kbapi.UpdateListJSONRequestBody{ + Id: kbapi.SecurityListsAPIListId(m.ListID.ValueString()), + Name: kbapi.SecurityListsAPIListName(m.Name.ValueString()), + Description: kbapi.SecurityListsAPIListDescription(m.Description.ValueString()), + } + + // Set optional fields + if !m.VersionID.IsNull() && !m.VersionID.IsUnknown() { + versionID := kbapi.SecurityListsAPIListVersionId(m.VersionID.ValueString()) + req.UnderscoreVersion = &versionID + } + + if !m.Meta.IsNull() && !m.Meta.IsUnknown() { + var metaMap kbapi.SecurityListsAPIListMetadata + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &metaMap); err != nil { + diags.AddError("Invalid meta JSON", err.Error()) + return nil, diags + } + req.Meta = &metaMap + } + + if !m.Version.IsNull() && !m.Version.IsUnknown() { + version := kbapi.SecurityListsAPIListVersion(m.Version.ValueInt64()) + req.Version = &version + } + + return req, diags +} + +// fromAPI converts the API response to Terraform model +func (m *SecurityListModel) fromAPI(ctx context.Context, apiList *kbapi.SecurityListsAPIList) diag.Diagnostics { + var diags diag.Diagnostics + + m.ID = types.StringValue(string(apiList.Id)) + m.ListID = types.StringValue(string(apiList.Id)) + m.Name = types.StringValue(string(apiList.Name)) + m.Description = types.StringValue(string(apiList.Description)) + m.Type = types.StringValue(string(apiList.Type)) + m.Immutable = types.BoolValue(apiList.Immutable) + m.Version = types.Int64Value(int64(apiList.Version)) + m.TieBreakerID = types.StringValue(apiList.TieBreakerId) + m.CreatedAt = types.StringValue(apiList.CreatedAt.String()) + m.CreatedBy = types.StringValue(apiList.CreatedBy) + m.UpdatedAt = types.StringValue(apiList.UpdatedAt.String()) + m.UpdatedBy = types.StringValue(apiList.UpdatedBy) + + // Set optional _version field + if apiList.UnderscoreVersion != nil { + m.VersionID = types.StringValue(string(*apiList.UnderscoreVersion)) + } else { + m.VersionID = types.StringNull() + } + + if apiList.Deserializer != nil { + m.Deserializer = types.StringValue(string(*apiList.Deserializer)) + } else { + m.Deserializer = types.StringNull() + } + + if apiList.Serializer != nil { + m.Serializer = types.StringValue(string(*apiList.Serializer)) + } else { + m.Serializer = types.StringNull() + } + + if apiList.Meta != nil { + metaBytes, err := json.Marshal(apiList.Meta) + if err != nil { + diags.AddError("Failed to marshal meta", err.Error()) + return diags + } + m.Meta = types.StringValue(string(metaBytes)) + } else { + m.Meta = types.StringNull() + } + + return diags +} diff --git a/internal/kibana/security_list/read.go b/internal/kibana/security_list/read.go new file mode 100644 index 000000000..b5027401e --- /dev/null +++ b/internal/kibana/security_list/read.go @@ -0,0 +1,50 @@ +package security_list + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state SecurityListModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + spaceID := state.SpaceID.ValueString() + listID := state.ListID.ValueString() + + params := &kbapi.ReadListParams{ + Id: kbapi.SecurityListsAPIListId(listID), + } + + readResp, diags := kibana_oapi.GetList(ctx, client, spaceID, params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Convert API response to model + diags = state.fromAPI(ctx, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} diff --git a/internal/kibana/security_list/resource-description.md b/internal/kibana/security_list/resource-description.md new file mode 100644 index 000000000..9b67b4e12 --- /dev/null +++ b/internal/kibana/security_list/resource-description.md @@ -0,0 +1,27 @@ +Manages Kibana security lists (also known as value lists). Security lists are used by exception items to define sets of values for matching or excluding in security rules. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_list" "ip_list" { + space_id = "default" + name = "Trusted IP Addresses" + description = "List of trusted IP addresses for security rules" + type = "ip" +} + +resource "elasticstack_kibana_security_list" "keyword_list" { + space_id = "security" + list_id = "custom-keywords" + name = "Custom Keywords" + description = "Custom keyword list for detection rules" + type = "keyword" +} +``` + +## Notes + +- Security lists define the type of data they can contain via the `type` attribute +- Once created, the `type` of a list cannot be changed +- Lists can be referenced by exception items to create more sophisticated matching rules +- The `list_id` is auto-generated if not provided diff --git a/internal/kibana/security_list/resource.go b/internal/kibana/security_list/resource.go new file mode 100644 index 000000000..51d6ad1a6 --- /dev/null +++ b/internal/kibana/security_list/resource.go @@ -0,0 +1,38 @@ +package security_list + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// Ensure provider defined types fully satisfy framework interfaces +var ( + _ resource.Resource = &securityListResource{} + _ resource.ResourceWithConfigure = &securityListResource{} + _ resource.ResourceWithImportState = &securityListResource{} +) + +func NewResource() resource.Resource { + return &securityListResource{} +} + +type securityListResource struct { + client *clients.ApiClient +} + +func (r *securityListResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_kibana_security_list" +} + +func (r *securityListResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +func (r *securityListResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), request, response) +} diff --git a/internal/kibana/security_list/schema.go b/internal/kibana/security_list/schema.go new file mode 100644 index 000000000..318dd87d8 --- /dev/null +++ b/internal/kibana/security_list/schema.go @@ -0,0 +1,122 @@ +package security_list + +import ( + "context" + _ "embed" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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" +) + +//go:embed resource-description.md +var securityListResourceDescription string + +func (r *securityListResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: securityListResourceDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The unique identifier of the security list (auto-generated by Kibana if not specified).", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "space_id": schema.StringAttribute{ + MarkdownDescription: "An identifier for the space. If space_id is not provided, the default space is used.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "list_id": schema.StringAttribute{ + MarkdownDescription: "The value list's human-readable identifier.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the security list.", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Describes the security list.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "Specifies the Elasticsearch data type of values the list contains. Valid values include: `binary`, `boolean`, `byte`, `date`, `date_nanos`, `date_range`, `double`, `double_range`, `float`, `float_range`, `geo_point`, `geo_shape`, `half_float`, `integer`, `integer_range`, `ip`, `ip_range`, `keyword`, `long`, `long_range`, `shape`, `short`, `text`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "binary", "boolean", "byte", "date", "date_nanos", "date_range", + "double", "double_range", "float", "float_range", "geo_point", "geo_shape", + "half_float", "integer", "integer_range", "ip", "ip_range", "keyword", + "long", "long_range", "shape", "short", "text", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "deserializer": schema.StringAttribute{ + MarkdownDescription: "Determines how retrieved list item values are presented. By default, list items are presented using Handlebars expressions based on the type.", + Optional: true, + Computed: true, + }, + "serializer": schema.StringAttribute{ + MarkdownDescription: "Determines how uploaded list item values are parsed. By default, list items are parsed using named regex groups based on the type.", + Optional: true, + Computed: true, + }, + "meta": schema.StringAttribute{ + MarkdownDescription: "Placeholder for metadata about the value list as JSON string.", + Optional: true, + }, + "version": schema.Int64Attribute{ + MarkdownDescription: "The document version number.", + Optional: true, + Computed: true, + }, + "version_id": schema.StringAttribute{ + MarkdownDescription: "The version id, normally returned by the API when the document is retrieved.", + Computed: true, + }, + "immutable": schema.BoolAttribute{ + MarkdownDescription: "Whether the list is immutable.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the list was created.", + Computed: true, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The user who created the list.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the list was last updated.", + Computed: true, + }, + "updated_by": schema.StringAttribute{ + MarkdownDescription: "The user who last updated the list.", + Computed: true, + }, + "tie_breaker_id": schema.StringAttribute{ + MarkdownDescription: "Field used in search to ensure all containers are sorted and returned correctly.", + Computed: true, + }, + }, + } +} diff --git a/internal/kibana/security_list/testdata/TestAccResourceSecurityList/create/main.tf b/internal/kibana/security_list/testdata/TestAccResourceSecurityList/create/main.tf new file mode 100644 index 000000000..8d7acc2eb --- /dev/null +++ b/internal/kibana/security_list/testdata/TestAccResourceSecurityList/create/main.tf @@ -0,0 +1,22 @@ +variable "list_id" { + type = string +} + +variable "name" { + type = string +} + +variable "description" { + type = string +} + +variable "type" { + type = string +} + +resource "elasticstack_kibana_security_list" "test" { + list_id = var.list_id + name = var.name + description = var.description + type = var.type +} diff --git a/internal/kibana/security_list/testdata/TestAccResourceSecurityList/update/main.tf b/internal/kibana/security_list/testdata/TestAccResourceSecurityList/update/main.tf new file mode 100644 index 000000000..8d7acc2eb --- /dev/null +++ b/internal/kibana/security_list/testdata/TestAccResourceSecurityList/update/main.tf @@ -0,0 +1,22 @@ +variable "list_id" { + type = string +} + +variable "name" { + type = string +} + +variable "description" { + type = string +} + +variable "type" { + type = string +} + +resource "elasticstack_kibana_security_list" "test" { + list_id = var.list_id + name = var.name + description = var.description + type = var.type +} diff --git a/internal/kibana/security_list/testdata/TestAccResourceSecurityList_KeywordType/keyword_type/main.tf b/internal/kibana/security_list/testdata/TestAccResourceSecurityList_KeywordType/keyword_type/main.tf new file mode 100644 index 000000000..8d7acc2eb --- /dev/null +++ b/internal/kibana/security_list/testdata/TestAccResourceSecurityList_KeywordType/keyword_type/main.tf @@ -0,0 +1,22 @@ +variable "list_id" { + type = string +} + +variable "name" { + type = string +} + +variable "description" { + type = string +} + +variable "type" { + type = string +} + +resource "elasticstack_kibana_security_list" "test" { + list_id = var.list_id + name = var.name + description = var.description + type = var.type +} diff --git a/internal/kibana/security_list/update.go b/internal/kibana/security_list/update.go new file mode 100644 index 000000000..d545d92da --- /dev/null +++ b/internal/kibana/security_list/update.go @@ -0,0 +1,80 @@ +package security_list + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *securityListResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan SecurityListModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state SecurityListModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Preserve version_id from state for optimistic locking + if state.VersionID.ValueString() != "" { + plan.VersionID = state.VersionID + } + + // Convert plan to API request + updateReq, diags := plan.toUpdateRequest() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get Kibana client + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Update the list + spaceID := plan.SpaceID.ValueString() + updateResp, diags := kibana_oapi.UpdateList(ctx, client, spaceID, *updateReq) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil || updateResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to update security list", "API returned empty response") + return + } + + // Read the updated list to populate state + readParams := &kbapi.ReadListParams{ + Id: updateResp.JSON200.Id, + } + + readResp, diags := kibana_oapi.GetList(ctx, client, spaceID, readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response + diags = plan.fromAPI(ctx, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 111c128f0..eb505cbed 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -32,6 +32,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/maintenance_window" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_detection_rule" + "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_list" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_list_item" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/spaces" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics/monitor" @@ -148,6 +149,7 @@ func (p *Provider) resources(ctx context.Context) []func() resource.Resource { func (p *Provider) experimentalResources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ security_list_item.NewResource, + security_list.NewResource, } }