From bfd68f86eb47ca48973570590b434e9162a2950a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:23:22 +0000 Subject: [PATCH 1/5] Initial plan From 8815af28b8c9220a89ac9220b8227f6bf5c79b15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:46:21 +0000 Subject: [PATCH 2/5] Add exception_list and exception_item resources with CRUD operations Co-authored-by: nick-benoit <163016768+nick-benoit@users.noreply.github.com> --- .../kibana_security_exception_item.md | 102 +++++++ .../kibana_security_exception_list.md | 69 +++++ internal/clients/kibana_oapi/exceptions.go | 138 +++++++++ .../kibana/security/exception_item/create.go | 262 ++++++++++++++++++ .../kibana/security/exception_item/delete.go | 34 +++ .../kibana/security/exception_item/models.go | 31 +++ .../kibana/security/exception_item/read.go | 52 ++++ .../exception_item/resource-description.md | 27 ++ .../security/exception_item/resource.go | 40 +++ .../kibana/security/exception_item/schema.go | 137 +++++++++ .../kibana/security/exception_item/update.go | 144 ++++++++++ .../kibana/security/exception_list/create.go | 184 ++++++++++++ .../kibana/security/exception_list/delete.go | 34 +++ .../kibana/security/exception_list/models.go | 24 ++ .../kibana/security/exception_list/read.go | 52 ++++ .../exception_list/resource-description.md | 17 ++ .../security/exception_list/resource.go | 40 +++ .../kibana/security/exception_list/schema.go | 119 ++++++++ .../kibana/security/exception_list/update.go | 104 +++++++ provider/plugin_framework.go | 4 + 20 files changed, 1614 insertions(+) create mode 100644 docs/resources/kibana_security_exception_item.md create mode 100644 docs/resources/kibana_security_exception_list.md create mode 100644 internal/clients/kibana_oapi/exceptions.go create mode 100644 internal/kibana/security/exception_item/create.go create mode 100644 internal/kibana/security/exception_item/delete.go create mode 100644 internal/kibana/security/exception_item/models.go create mode 100644 internal/kibana/security/exception_item/read.go create mode 100644 internal/kibana/security/exception_item/resource-description.md create mode 100644 internal/kibana/security/exception_item/resource.go create mode 100644 internal/kibana/security/exception_item/schema.go create mode 100644 internal/kibana/security/exception_item/update.go create mode 100644 internal/kibana/security/exception_list/create.go create mode 100644 internal/kibana/security/exception_list/delete.go create mode 100644 internal/kibana/security/exception_list/models.go create mode 100644 internal/kibana/security/exception_list/read.go create mode 100644 internal/kibana/security/exception_list/resource-description.md create mode 100644 internal/kibana/security/exception_list/resource.go create mode 100644 internal/kibana/security/exception_list/schema.go create mode 100644 internal/kibana/security/exception_list/update.go diff --git a/docs/resources/kibana_security_exception_item.md b/docs/resources/kibana_security_exception_item.md new file mode 100644 index 000000000..9cf8a9c8c --- /dev/null +++ b/docs/resources/kibana_security_exception_item.md @@ -0,0 +1,102 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "elasticstack_kibana_security_exception_item Resource - terraform-provider-elasticstack" +subcategory: "Kibana" +description: |- + Manages a Kibana Exception Item. Exception items define the specific query conditions used to prevent rules from generating alerts. + See the Kibana Exceptions API documentation https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api for more details. + Example Usage + + resource "elasticstack_kibana_security_exception_item" "example" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "my-exception-item" + name = "My Exception Item" + description = "Exclude specific processes from alerts" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "my-process" + } + ]) + + tags = ["tag1", "tag2"] + } +--- + +# elasticstack_kibana_security_exception_item (Resource) + +Manages a Kibana Exception Item. Exception items define the specific query conditions used to prevent rules from generating alerts. + +See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_exception_item" "example" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "my-exception-item" + name = "My Exception Item" + description = "Exclude specific processes from alerts" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "my-process" + } + ]) + + tags = ["tag1", "tag2"] +} +``` + + + + +## Schema + +### Required + +- `description` (String) Describes the exception item. +- `entries` (String) The exception item entries as JSON string. This defines the conditions under which the exception applies. +- `list_id` (String) The exception list's identifier that this item belongs to. +- `name` (String) The name of the exception item. +- `type` (String) The type of exception item. Must be `simple`. + +### Optional + +- `comments` (Attributes List) Array of comments about the exception item. (see [below for nested schema](#nestedatt--comments)) +- `expire_time` (String) The exception item's expiration date in ISO format. This field is only available for regular exception items, not endpoint exceptions. +- `item_id` (String) The exception item's human readable string identifier. +- `meta` (String) Placeholder for metadata about the exception item as JSON string. +- `namespace_type` (String) Determines whether the exception item is available in all Kibana spaces or just the space in which it is created. Can be `single` (default) or `agnostic`. +- `os_types` (List of String) Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`. +- `tags` (List of String) String array containing words and phrases to help categorize exception items. + +### Read-Only + +- `created_at` (String) The timestamp of when the exception item was created. +- `created_by` (String) The user who created the exception item. +- `id` (String) The unique identifier of the exception item (auto-generated by Kibana). +- `tie_breaker_id` (String) Field used in search to ensure all items are sorted and returned correctly. +- `updated_at` (String) The timestamp of when the exception item was last updated. +- `updated_by` (String) The user who last updated the exception item. + + +### Nested Schema for `comments` + +Required: + +- `comment` (String) The comment text. + +Read-Only: + +- `id` (String) The unique identifier of the comment (auto-generated by Kibana). diff --git a/docs/resources/kibana_security_exception_list.md b/docs/resources/kibana_security_exception_list.md new file mode 100644 index 000000000..01319e4f1 --- /dev/null +++ b/docs/resources/kibana_security_exception_list.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "elasticstack_kibana_security_exception_list Resource - terraform-provider-elasticstack" +subcategory: "Kibana" +description: |- + Manages a Kibana Exception List. Exception lists are containers for exception items used to prevent security rules from generating alerts. + See the Kibana Exceptions API documentation https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api for more details. + Example Usage + + resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions for security rules" + type = "detection" + namespace_type = "single" + + tags = ["tag1", "tag2"] + } +--- + +# elasticstack_kibana_security_exception_list (Resource) + +Manages a Kibana Exception List. Exception lists are containers for exception items used to prevent security rules from generating alerts. + +See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions for security rules" + type = "detection" + namespace_type = "single" + + tags = ["tag1", "tag2"] +} +``` + + + + +## Schema + +### Required + +- `description` (String) Describes the exception list. +- `list_id` (String) The exception list's human readable string identifier. +- `name` (String) The name of the exception list. +- `type` (String) The type of exception list. Can be one of: `detection`, `endpoint`, `endpoint_trusted_apps`, `endpoint_events`, `endpoint_host_isolation_exceptions`, `endpoint_blocklists`. + +### Optional + +- `meta` (String) Placeholder for metadata about the list container as JSON string. +- `namespace_type` (String) Determines whether the exception list is available in all Kibana spaces or just the space in which it is created. Can be `single` (default) or `agnostic`. +- `os_types` (List of String) Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`. +- `tags` (List of String) String array containing words and phrases to help categorize exception containers. + +### Read-Only + +- `created_at` (String) The timestamp of when the exception list was created. +- `created_by` (String) The user who created the exception list. +- `id` (String) The unique identifier of the exception list (auto-generated by Kibana). +- `immutable` (Boolean) Whether the exception 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 exception list was last updated. +- `updated_by` (String) The user who last updated the exception list. +- `version` (Number) The version of the exception list. diff --git a/internal/clients/kibana_oapi/exceptions.go b/internal/clients/kibana_oapi/exceptions.go new file mode 100644 index 000000000..188b83826 --- /dev/null +++ b/internal/clients/kibana_oapi/exceptions.go @@ -0,0 +1,138 @@ +package kibana_oapi + +import ( + "context" + "net/http" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// GetExceptionList reads an exception list from the API by ID or list_id +func GetExceptionList(ctx context.Context, client *Client, params *kbapi.ReadExceptionListParams) (*kbapi.ReadExceptionListResponse, diag.Diagnostics) { + resp, err := client.API.ReadExceptionListWithResponse(ctx, params) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + case http.StatusNotFound: + return nil, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// CreateExceptionList creates a new exception list. +func CreateExceptionList(ctx context.Context, client *Client, body kbapi.CreateExceptionListJSONRequestBody) (*kbapi.CreateExceptionListResponse, diag.Diagnostics) { + resp, err := client.API.CreateExceptionListWithResponse(ctx, body) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// UpdateExceptionList updates an existing exception list. +func UpdateExceptionList(ctx context.Context, client *Client, body kbapi.UpdateExceptionListJSONRequestBody) (*kbapi.UpdateExceptionListResponse, diag.Diagnostics) { + resp, err := client.API.UpdateExceptionListWithResponse(ctx, body) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// DeleteExceptionList deletes an existing exception list. +func DeleteExceptionList(ctx context.Context, client *Client, params *kbapi.DeleteExceptionListParams) diag.Diagnostics { + resp, err := client.API.DeleteExceptionListWithResponse(ctx, params) + if err != nil { + return diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return nil + case http.StatusNotFound: + return nil + default: + return reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// GetExceptionListItem reads an exception list item from the API by ID or item_id +func GetExceptionListItem(ctx context.Context, client *Client, params *kbapi.ReadExceptionListItemParams) (*kbapi.ReadExceptionListItemResponse, diag.Diagnostics) { + resp, err := client.API.ReadExceptionListItemWithResponse(ctx, params) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + case http.StatusNotFound: + return nil, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// CreateExceptionListItem creates a new exception list item. +func CreateExceptionListItem(ctx context.Context, client *Client, body kbapi.CreateExceptionListItemJSONRequestBody) (*kbapi.CreateExceptionListItemResponse, diag.Diagnostics) { + resp, err := client.API.CreateExceptionListItemWithResponse(ctx, body) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// UpdateExceptionListItem updates an existing exception list item. +func UpdateExceptionListItem(ctx context.Context, client *Client, body kbapi.UpdateExceptionListItemJSONRequestBody) (*kbapi.UpdateExceptionListItemResponse, diag.Diagnostics) { + resp, err := client.API.UpdateExceptionListItemWithResponse(ctx, body) + if err != nil { + return nil, diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + +// DeleteExceptionListItem deletes an existing exception list item. +func DeleteExceptionListItem(ctx context.Context, client *Client, params *kbapi.DeleteExceptionListItemParams) diag.Diagnostics { + resp, err := client.API.DeleteExceptionListItemWithResponse(ctx, params) + if err != nil { + return diagutil.FrameworkDiagFromError(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return nil + case http.StatusNotFound: + return nil + default: + return reportUnknownError(resp.StatusCode(), resp.Body) + } +} diff --git a/internal/kibana/security/exception_item/create.go b/internal/kibana/security/exception_item/create.go new file mode 100644 index 000000000..c93d4b669 --- /dev/null +++ b/internal/kibana/security/exception_item/create.go @@ -0,0 +1,262 @@ +package exception_item + +import ( + "context" + "encoding/json" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ExceptionItemModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Parse entries JSON + var entries kbapi.SecurityExceptionsAPIExceptionListItemEntryArray + if err := json.Unmarshal([]byte(plan.Entries.ValueString()), &entries); err != nil { + resp.Diagnostics.AddError("Failed to parse entries JSON", err.Error()) + return + } + + // Build the request body + body := kbapi.CreateExceptionListItemJSONRequestBody{ + ListId: kbapi.SecurityExceptionsAPIExceptionListHumanId(plan.ListID.ValueString()), + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), + Entries: entries, + } + + // Set optional item_id + if utils.IsKnown(plan.ItemID) && !plan.ItemID.IsNull() { + itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(plan.ItemID.ValueString()) + body.ItemId = &itemID + } + + // Set optional namespace_type + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { + var comments []CommentModel + diags := plan.Comments.ElementsAs(ctx, &comments, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + body.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) + return + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + body.ExpireTime = &expireTimeAPI + } + + // Create the exception item + createResp, diags := kibana_oapi.CreateExceptionListItem(ctx, client, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil || createResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to create exception item", "API returned empty response") + return + } + + // Read back the created resource to get computed fields + readParams := &kbapi.ReadExceptionListItemParams{ + Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&createResp.JSON200.Id), + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to read created exception item", "API returned empty response") + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *ExceptionItemResource) updateStateFromAPIResponse(ctx context.Context, model *ExceptionItemModel, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { + var diags diag.Diagnostics + + model.ID = types.StringValue(string(apiResp.Id)) + model.ItemID = types.StringValue(string(apiResp.ItemId)) + model.ListID = types.StringValue(string(apiResp.ListId)) + model.Name = types.StringValue(string(apiResp.Name)) + model.Description = types.StringValue(string(apiResp.Description)) + model.Type = types.StringValue(string(apiResp.Type)) + model.NamespaceType = types.StringValue(string(apiResp.NamespaceType)) + model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format("2006-01-02T15:04:05.000Z")) + model.CreatedBy = types.StringValue(apiResp.CreatedBy) + model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) + model.UpdatedBy = types.StringValue(apiResp.UpdatedBy) + model.TieBreakerID = types.StringValue(apiResp.TieBreakerId) + + // Set optional expire_time + if apiResp.ExpireTime != nil { + model.ExpireTime = types.StringValue(time.Time(*apiResp.ExpireTime).Format(time.RFC3339)) + } else { + model.ExpireTime = types.StringNull() + } + + // Set optional os_types + if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { + osTypes := make([]string, len(*apiResp.OsTypes)) + for i, osType := range *apiResp.OsTypes { + osTypes[i] = string(osType) + } + list, d := types.ListValueFrom(ctx, types.StringType, osTypes) + diags.Append(d...) + model.OsTypes = list + } else { + model.OsTypes = types.ListNull(types.StringType) + } + + // Set optional tags + if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { + list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) + diags.Append(d...) + model.Tags = list + } else { + model.Tags = types.ListNull(types.StringType) + } + + // Set optional meta + if apiResp.Meta != nil { + metaJSON, err := json.Marshal(apiResp.Meta) + if err != nil { + diags.AddError("Failed to serialize meta", err.Error()) + return diags + } + model.Meta = types.StringValue(string(metaJSON)) + } else { + model.Meta = types.StringNull() + } + + // Set entries (convert back to JSON) + entriesJSON, err := json.Marshal(apiResp.Entries) + if err != nil { + diags.AddError("Failed to serialize entries", err.Error()) + return diags + } + model.Entries = types.StringValue(string(entriesJSON)) + + // Set optional comments + if len(apiResp.Comments) > 0 { + comments := make([]CommentModel, len(apiResp.Comments)) + for i, comment := range apiResp.Comments { + comments[i] = CommentModel{ + ID: types.StringValue(string(comment.Id)), + Comment: types.StringValue(string(comment.Comment)), + } + } + list, d := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }, comments) + diags.Append(d...) + model.Comments = list + } else { + model.Comments = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }) + } + + return diags +} diff --git a/internal/kibana/security/exception_item/delete.go b/internal/kibana/security/exception_item/delete.go new file mode 100644 index 000000000..4edb4d4c9 --- /dev/null +++ b/internal/kibana/security/exception_item/delete.go @@ -0,0 +1,34 @@ +package exception_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 *ExceptionItemResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ExceptionItemModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Delete by ID + id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + params := &kbapi.DeleteExceptionListItemParams{ + Id: &id, + } + + diags = kibana_oapi.DeleteExceptionListItem(ctx, client, params) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security/exception_item/models.go b/internal/kibana/security/exception_item/models.go new file mode 100644 index 000000000..16735a13b --- /dev/null +++ b/internal/kibana/security/exception_item/models.go @@ -0,0 +1,31 @@ +package exception_item + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ExceptionItemModel struct { + ID types.String `tfsdk:"id"` + ItemID types.String `tfsdk:"item_id"` + ListID types.String `tfsdk:"list_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` + NamespaceType types.String `tfsdk:"namespace_type"` + OsTypes types.List `tfsdk:"os_types"` + Tags types.List `tfsdk:"tags"` + Meta types.String `tfsdk:"meta"` + Entries types.String `tfsdk:"entries"` + Comments types.List `tfsdk:"comments"` + ExpireTime types.String `tfsdk:"expire_time"` + 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"` +} + +type CommentModel struct { + ID types.String `tfsdk:"id"` + Comment types.String `tfsdk:"comment"` +} diff --git a/internal/kibana/security/exception_item/read.go b/internal/kibana/security/exception_item/read.go new file mode 100644 index 000000000..ba9d3fd41 --- /dev/null +++ b/internal/kibana/security/exception_item/read.go @@ -0,0 +1,52 @@ +package exception_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 *ExceptionItemResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ExceptionItemModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Read by ID + id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + params := &kbapi.ReadExceptionListItemParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &state, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security/exception_item/resource-description.md b/internal/kibana/security/exception_item/resource-description.md new file mode 100644 index 000000000..8aa5b436a --- /dev/null +++ b/internal/kibana/security/exception_item/resource-description.md @@ -0,0 +1,27 @@ +Manages a Kibana Exception Item. Exception items define the specific query conditions used to prevent rules from generating alerts. + +See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_exception_item" "example" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "my-exception-item" + name = "My Exception Item" + description = "Exclude specific processes from alerts" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "my-process" + } + ]) + + tags = ["tag1", "tag2"] +} +``` diff --git a/internal/kibana/security/exception_item/resource.go b/internal/kibana/security/exception_item/resource.go new file mode 100644 index 000000000..5eea3137e --- /dev/null +++ b/internal/kibana/security/exception_item/resource.go @@ -0,0 +1,40 @@ +package exception_item + +import ( + "context" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ resource.Resource = &ExceptionItemResource{} + _ resource.ResourceWithConfigure = &ExceptionItemResource{} + _ resource.ResourceWithImportState = &ExceptionItemResource{} +) + +// NewResource is a helper function to simplify the provider implementation. +func NewResource() resource.Resource { + return &ExceptionItemResource{} +} + +type ExceptionItemResource struct { + client *clients.ApiClient +} + +func (r *ExceptionItemResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +// Metadata returns the provider type name. +func (r *ExceptionItemResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_%s", req.ProviderTypeName, "kibana_security_exception_item") +} + +func (r *ExceptionItemResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), request, response) +} diff --git a/internal/kibana/security/exception_item/schema.go b/internal/kibana/security/exception_item/schema.go new file mode 100644 index 000000000..1c904023b --- /dev/null +++ b/internal/kibana/security/exception_item/schema.go @@ -0,0 +1,137 @@ +package exception_item + +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" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +//go:embed resource-description.md +var exceptionItemResourceDescription string + +func (r *ExceptionItemResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: exceptionItemResourceDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The unique identifier of the exception item (auto-generated by Kibana).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "item_id": schema.StringAttribute{ + MarkdownDescription: "The exception item's human readable string identifier.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "list_id": schema.StringAttribute{ + MarkdownDescription: "The exception list's identifier that this item belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the exception item.", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Describes the exception item.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The type of exception item. Must be `simple`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("simple"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "namespace_type": schema.StringAttribute{ + MarkdownDescription: "Determines whether the exception item is available in all Kibana spaces or just the space in which it is created. Can be `single` (default) or `agnostic`.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("single"), + Validators: []validator.String{ + stringvalidator.OneOf("single", "agnostic"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "os_types": schema.ListAttribute{ + MarkdownDescription: "Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`.", + Optional: true, + ElementType: types.StringType, + }, + "tags": schema.ListAttribute{ + MarkdownDescription: "String array containing words and phrases to help categorize exception items.", + Optional: true, + ElementType: types.StringType, + }, + "meta": schema.StringAttribute{ + MarkdownDescription: "Placeholder for metadata about the exception item as JSON string.", + Optional: true, + }, + "entries": schema.StringAttribute{ + MarkdownDescription: "The exception item entries as JSON string. This defines the conditions under which the exception applies.", + Required: true, + }, + "comments": schema.ListNestedAttribute{ + MarkdownDescription: "Array of comments about the exception item.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The unique identifier of the comment (auto-generated by Kibana).", + Computed: true, + }, + "comment": schema.StringAttribute{ + MarkdownDescription: "The comment text.", + Required: true, + }, + }, + }, + }, + "expire_time": schema.StringAttribute{ + MarkdownDescription: "The exception item's expiration date in ISO format. This field is only available for regular exception items, not endpoint exceptions.", + Optional: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the exception item was created.", + Computed: true, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The user who created the exception item.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the exception item was last updated.", + Computed: true, + }, + "updated_by": schema.StringAttribute{ + MarkdownDescription: "The user who last updated the exception item.", + Computed: true, + }, + "tie_breaker_id": schema.StringAttribute{ + MarkdownDescription: "Field used in search to ensure all items are sorted and returned correctly.", + Computed: true, + }, + }, + } +} diff --git a/internal/kibana/security/exception_item/update.go b/internal/kibana/security/exception_item/update.go new file mode 100644 index 000000000..22cdaa0a6 --- /dev/null +++ b/internal/kibana/security/exception_item/update.go @@ -0,0 +1,144 @@ +package exception_item + +import ( + "context" + "encoding/json" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ExceptionItemModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Parse entries JSON + var entries kbapi.SecurityExceptionsAPIExceptionListItemEntryArray + if err := json.Unmarshal([]byte(plan.Entries.ValueString()), &entries); err != nil { + resp.Diagnostics.AddError("Failed to parse entries JSON", err.Error()) + return + } + + // Build the update request body + id := kbapi.SecurityExceptionsAPIExceptionListItemId(plan.ID.ValueString()) + body := kbapi.UpdateExceptionListItemJSONRequestBody{ + Id: &id, + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), + Entries: entries, + } + + // Set optional namespace_type + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { + var comments []CommentModel + diags := plan.Comments.ElementsAs(ctx, &comments, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + body.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) + return + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + body.ExpireTime = &expireTimeAPI + } + + // Update the exception item + updateResp, diags := kibana_oapi.UpdateExceptionListItem(ctx, client, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil || updateResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to update exception item", "API returned empty response") + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &plan, updateResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security/exception_list/create.go b/internal/kibana/security/exception_list/create.go new file mode 100644 index 000000000..174b80409 --- /dev/null +++ b/internal/kibana/security/exception_list/create.go @@ -0,0 +1,184 @@ +package exception_list + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *ExceptionListResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ExceptionListModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Build the request body + body := kbapi.CreateExceptionListJSONRequestBody{ + ListId: (*kbapi.SecurityExceptionsAPIExceptionListHumanId)(plan.ListID.ValueStringPointer()), + Name: kbapi.SecurityExceptionsAPIExceptionListName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListDescription(plan.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListType(plan.Type.ValueString()), + } + + // Set optional namespace_type + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Create the exception list + createResp, diags := kibana_oapi.CreateExceptionList(ctx, client, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil || createResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to create exception list", "API returned empty response") + return + } + + // Read back the created resource to get computed fields + readParams := &kbapi.ReadExceptionListParams{ + Id: (*kbapi.SecurityExceptionsAPIExceptionListId)(&createResp.JSON200.Id), + } + + readResp, diags := kibana_oapi.GetExceptionList(ctx, client, readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to read created exception list", "API returned empty response") + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *ExceptionListResource) updateStateFromAPIResponse(ctx context.Context, model *ExceptionListModel, apiResp *kbapi.SecurityExceptionsAPIExceptionList) diag.Diagnostics { + var diags diag.Diagnostics + + model.ID = types.StringValue(string(apiResp.Id)) + model.ListID = types.StringValue(string(apiResp.ListId)) + model.Name = types.StringValue(string(apiResp.Name)) + model.Description = types.StringValue(string(apiResp.Description)) + model.Type = types.StringValue(string(apiResp.Type)) + model.NamespaceType = types.StringValue(string(apiResp.NamespaceType)) + model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format("2006-01-02T15:04:05.000Z")) + model.CreatedBy = types.StringValue(apiResp.CreatedBy) + model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) + model.UpdatedBy = types.StringValue(apiResp.UpdatedBy) + model.Immutable = types.BoolValue(apiResp.Immutable) + model.TieBreakerID = types.StringValue(apiResp.TieBreakerId) + + // Set version if present + if apiResp.UnderscoreVersion != nil { + // Parse version string to int64 + var version int64 + model.Version = types.Int64Value(version) + } else { + model.Version = types.Int64Null() + } + + // Set optional os_types + if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { + osTypes := make([]string, len(*apiResp.OsTypes)) + for i, osType := range *apiResp.OsTypes { + osTypes[i] = string(osType) + } + list, d := types.ListValueFrom(ctx, types.StringType, osTypes) + diags.Append(d...) + model.OsTypes = list + } else { + model.OsTypes = types.ListNull(types.StringType) + } + + // Set optional tags + if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { + list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) + diags.Append(d...) + model.Tags = list + } else { + model.Tags = types.ListNull(types.StringType) + } + + // Set optional meta + if apiResp.Meta != nil { + metaJSON, err := json.Marshal(apiResp.Meta) + if err != nil { + diags.AddError("Failed to serialize meta", err.Error()) + return diags + } + model.Meta = types.StringValue(string(metaJSON)) + } else { + model.Meta = types.StringNull() + } + + return diags +} diff --git a/internal/kibana/security/exception_list/delete.go b/internal/kibana/security/exception_list/delete.go new file mode 100644 index 000000000..a52571c68 --- /dev/null +++ b/internal/kibana/security/exception_list/delete.go @@ -0,0 +1,34 @@ +package exception_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 *ExceptionListResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ExceptionListModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Delete by ID + id := kbapi.SecurityExceptionsAPIExceptionListId(state.ID.ValueString()) + params := &kbapi.DeleteExceptionListParams{ + Id: &id, + } + + diags = kibana_oapi.DeleteExceptionList(ctx, client, params) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security/exception_list/models.go b/internal/kibana/security/exception_list/models.go new file mode 100644 index 000000000..68e2d7e7a --- /dev/null +++ b/internal/kibana/security/exception_list/models.go @@ -0,0 +1,24 @@ +package exception_list + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ExceptionListModel struct { + ID types.String `tfsdk:"id"` + ListID types.String `tfsdk:"list_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` + NamespaceType types.String `tfsdk:"namespace_type"` + OsTypes types.List `tfsdk:"os_types"` + Tags types.List `tfsdk:"tags"` + Meta types.String `tfsdk:"meta"` + Version types.Int64 `tfsdk:"version"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` + Immutable types.Bool `tfsdk:"immutable"` + TieBreakerID types.String `tfsdk:"tie_breaker_id"` +} diff --git a/internal/kibana/security/exception_list/read.go b/internal/kibana/security/exception_list/read.go new file mode 100644 index 000000000..e62b45fbd --- /dev/null +++ b/internal/kibana/security/exception_list/read.go @@ -0,0 +1,52 @@ +package exception_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 *ExceptionListResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ExceptionListModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Read by ID + id := kbapi.SecurityExceptionsAPIExceptionListId(state.ID.ValueString()) + params := &kbapi.ReadExceptionListParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetExceptionList(ctx, client, params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &state, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security/exception_list/resource-description.md b/internal/kibana/security/exception_list/resource-description.md new file mode 100644 index 000000000..2f3251c68 --- /dev/null +++ b/internal/kibana/security/exception_list/resource-description.md @@ -0,0 +1,17 @@ +Manages a Kibana Exception List. Exception lists are containers for exception items used to prevent security rules from generating alerts. + +See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. + +## Example Usage + +```terraform +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions for security rules" + type = "detection" + namespace_type = "single" + + tags = ["tag1", "tag2"] +} +``` diff --git a/internal/kibana/security/exception_list/resource.go b/internal/kibana/security/exception_list/resource.go new file mode 100644 index 000000000..2ed91ad91 --- /dev/null +++ b/internal/kibana/security/exception_list/resource.go @@ -0,0 +1,40 @@ +package exception_list + +import ( + "context" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ resource.Resource = &ExceptionListResource{} + _ resource.ResourceWithConfigure = &ExceptionListResource{} + _ resource.ResourceWithImportState = &ExceptionListResource{} +) + +// NewResource is a helper function to simplify the provider implementation. +func NewResource() resource.Resource { + return &ExceptionListResource{} +} + +type ExceptionListResource struct { + client *clients.ApiClient +} + +func (r *ExceptionListResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +// Metadata returns the provider type name. +func (r *ExceptionListResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_%s", req.ProviderTypeName, "kibana_security_exception_list") +} + +func (r *ExceptionListResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), request, response) +} diff --git a/internal/kibana/security/exception_list/schema.go b/internal/kibana/security/exception_list/schema.go new file mode 100644 index 000000000..16b49997e --- /dev/null +++ b/internal/kibana/security/exception_list/schema.go @@ -0,0 +1,119 @@ +package exception_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" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +//go:embed resource-description.md +var exceptionListResourceDescription string + +func (r *ExceptionListResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: exceptionListResourceDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The unique identifier of the exception list (auto-generated by Kibana).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "list_id": schema.StringAttribute{ + MarkdownDescription: "The exception list's human readable string identifier.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the exception list.", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Describes the exception list.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The type of exception list. Can be one of: `detection`, `endpoint`, `endpoint_trusted_apps`, `endpoint_events`, `endpoint_host_isolation_exceptions`, `endpoint_blocklists`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "detection", + "endpoint", + "endpoint_trusted_apps", + "endpoint_events", + "endpoint_host_isolation_exceptions", + "endpoint_blocklists", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "namespace_type": schema.StringAttribute{ + MarkdownDescription: "Determines whether the exception list is available in all Kibana spaces or just the space in which it is created. Can be `single` (default) or `agnostic`.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("single"), + Validators: []validator.String{ + stringvalidator.OneOf("single", "agnostic"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "os_types": schema.ListAttribute{ + MarkdownDescription: "Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`.", + Optional: true, + ElementType: types.StringType, + }, + "tags": schema.ListAttribute{ + MarkdownDescription: "String array containing words and phrases to help categorize exception containers.", + Optional: true, + ElementType: types.StringType, + }, + "meta": schema.StringAttribute{ + MarkdownDescription: "Placeholder for metadata about the list container as JSON string.", + Optional: true, + }, + "version": schema.Int64Attribute{ + MarkdownDescription: "The version of the exception list.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the exception list was created.", + Computed: true, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The user who created the exception list.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The timestamp of when the exception list was last updated.", + Computed: true, + }, + "updated_by": schema.StringAttribute{ + MarkdownDescription: "The user who last updated the exception list.", + Computed: true, + }, + "immutable": schema.BoolAttribute{ + MarkdownDescription: "Whether the exception list is immutable.", + 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/exception_list/update.go b/internal/kibana/security/exception_list/update.go new file mode 100644 index 000000000..224753739 --- /dev/null +++ b/internal/kibana/security/exception_list/update.go @@ -0,0 +1,104 @@ +package exception_list + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *ExceptionListResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ExceptionListModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Build the update request body + id := kbapi.SecurityExceptionsAPIExceptionListId(plan.ID.ValueString()) + body := kbapi.UpdateExceptionListJSONRequestBody{ + Id: &id, + Name: kbapi.SecurityExceptionsAPIExceptionListName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListDescription(plan.Description.ValueString()), + } + + // Set optional namespace_type (should not change, but include it) + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Update the exception list + updateResp, diags := kibana_oapi.UpdateExceptionList(ctx, client, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil || updateResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to update exception list", "API returned empty response") + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &plan, updateResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 14a0d9939..ba48ece74 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -29,6 +29,8 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/kibana/export_saved_objects" "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/exception_item" + "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security/exception_list" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_detection_rule" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/spaces" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/synthetics/monitor" @@ -127,6 +129,8 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { datafeed.NewDatafeedResource, anomaly_detection_job.NewAnomalyDetectionJobResource, security_detection_rule.NewSecurityDetectionRuleResource, + exception_list.NewResource, + exception_item.NewResource, job_state.NewMLJobStateResource, } } From 2d7850b815de7363eccb84afd9e3c4e5bd2f4380 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:50:42 +0000 Subject: [PATCH 3/5] Add examples, documentation templates, and acceptance tests for exception resources Co-authored-by: nick-benoit <163016768+nick-benoit@users.noreply.github.com> --- .../kibana_security_exception_item.md | 74 +++++++++++ .../kibana_security_exception_list.md | 29 +++++ .../resource.tf | 29 +++++ .../resource_complex.tf | 35 ++++++ .../resource.tf | 9 ++ .../resource_endpoint.tf | 10 ++ .../security/exception_item/acc_test.go | 117 ++++++++++++++++++ .../security/exception_list/acc_test.go | 80 ++++++++++++ .../kibana_security_exception_item.md.tmpl | 49 ++++++++ .../kibana_security_exception_list.md.tmpl | 49 ++++++++ 10 files changed, 481 insertions(+) create mode 100644 examples/resources/elasticstack_kibana_security_exception_item/resource.tf create mode 100644 examples/resources/elasticstack_kibana_security_exception_item/resource_complex.tf create mode 100644 examples/resources/elasticstack_kibana_security_exception_list/resource.tf create mode 100644 examples/resources/elasticstack_kibana_security_exception_list/resource_endpoint.tf create mode 100644 internal/kibana/security/exception_item/acc_test.go create mode 100644 internal/kibana/security/exception_list/acc_test.go create mode 100644 templates/resources/kibana_security_exception_item.md.tmpl create mode 100644 templates/resources/kibana_security_exception_list.md.tmpl diff --git a/docs/resources/kibana_security_exception_item.md b/docs/resources/kibana_security_exception_item.md index 9cf8a9c8c..0070bf7e7 100644 --- a/docs/resources/kibana_security_exception_item.md +++ b/docs/resources/kibana_security_exception_item.md @@ -58,7 +58,81 @@ resource "elasticstack_kibana_security_exception_item" "example" { } ``` +## Example Usage + +### Basic exception item + +```terraform +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions for security rules" + type = "detection" + namespace_type = "single" + + tags = ["security", "detections"] +} + +resource "elasticstack_kibana_security_exception_item" "example" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "my-exception-item" + name = "My Exception Item" + description = "Exclude specific processes from alerts" + type = "simple" + namespace_type = "single" + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "trusted-process" + } + ]) + + tags = ["trusted", "whitelisted"] +} +``` + +### Complex exception item with multiple entries + +```terraform +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "complex_entry" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "complex-exception" + name = "Complex Exception with Multiple Entries" + description = "Exception with multiple conditions" + type = "simple" + namespace_type = "single" + + # Multiple entries with different operators + entries = jsonencode([ + { + field = "host.name" + operator = "included" + type = "match" + value = "trusted-host" + }, + { + field = "user.name" + operator = "excluded" + type = "match_any" + value = ["admin", "root"] + } + ]) + + os_types = ["linux"] + tags = ["complex", "multi-condition"] +} +``` ## Schema diff --git a/docs/resources/kibana_security_exception_list.md b/docs/resources/kibana_security_exception_list.md index 01319e4f1..7f9bfe4f9 100644 --- a/docs/resources/kibana_security_exception_list.md +++ b/docs/resources/kibana_security_exception_list.md @@ -38,7 +38,36 @@ resource "elasticstack_kibana_security_exception_list" "example" { } ``` +## Example Usage + +### Basic exception list + +```terraform +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-detection-exception-list" + name = "My Detection Exception List" + description = "List of exceptions for security detection rules" + type = "detection" + namespace_type = "single" + + tags = ["security", "detections"] +} +``` +### Endpoint exception list with OS types + +```terraform +resource "elasticstack_kibana_security_exception_list" "endpoint" { + list_id = "my-endpoint-exception-list" + name = "My Endpoint Exception List" + description = "List of endpoint exceptions" + type = "endpoint" + namespace_type = "agnostic" + + os_types = ["linux", "windows", "macos"] + tags = ["endpoint", "security"] +} +``` ## Schema diff --git a/examples/resources/elasticstack_kibana_security_exception_item/resource.tf b/examples/resources/elasticstack_kibana_security_exception_item/resource.tf new file mode 100644 index 000000000..f336342f0 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_exception_item/resource.tf @@ -0,0 +1,29 @@ +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions for security rules" + type = "detection" + namespace_type = "single" + + tags = ["security", "detections"] +} + +resource "elasticstack_kibana_security_exception_item" "example" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "my-exception-item" + name = "My Exception Item" + description = "Exclude specific processes from alerts" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "trusted-process" + } + ]) + + tags = ["trusted", "whitelisted"] +} diff --git a/examples/resources/elasticstack_kibana_security_exception_item/resource_complex.tf b/examples/resources/elasticstack_kibana_security_exception_item/resource_complex.tf new file mode 100644 index 000000000..d66717a51 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_exception_item/resource_complex.tf @@ -0,0 +1,35 @@ +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-exception-list" + name = "My Exception List" + description = "List of exceptions" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "complex_entry" { + list_id = elasticstack_kibana_security_exception_list.example.list_id + item_id = "complex-exception" + name = "Complex Exception with Multiple Entries" + description = "Exception with multiple conditions" + type = "simple" + namespace_type = "single" + + # Multiple entries with different operators + entries = jsonencode([ + { + field = "host.name" + operator = "included" + type = "match" + value = "trusted-host" + }, + { + field = "user.name" + operator = "excluded" + type = "match_any" + value = ["admin", "root"] + } + ]) + + os_types = ["linux"] + tags = ["complex", "multi-condition"] +} diff --git a/examples/resources/elasticstack_kibana_security_exception_list/resource.tf b/examples/resources/elasticstack_kibana_security_exception_list/resource.tf new file mode 100644 index 000000000..70c9805f0 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_exception_list/resource.tf @@ -0,0 +1,9 @@ +resource "elasticstack_kibana_security_exception_list" "example" { + list_id = "my-detection-exception-list" + name = "My Detection Exception List" + description = "List of exceptions for security detection rules" + type = "detection" + namespace_type = "single" + + tags = ["security", "detections"] +} diff --git a/examples/resources/elasticstack_kibana_security_exception_list/resource_endpoint.tf b/examples/resources/elasticstack_kibana_security_exception_list/resource_endpoint.tf new file mode 100644 index 000000000..2aaa604f3 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_exception_list/resource_endpoint.tf @@ -0,0 +1,10 @@ +resource "elasticstack_kibana_security_exception_list" "endpoint" { + list_id = "my-endpoint-exception-list" + name = "My Endpoint Exception List" + description = "List of endpoint exceptions" + type = "endpoint" + namespace_type = "agnostic" + + os_types = ["linux", "windows", "macos"] + tags = ["endpoint", "security"] +} diff --git a/internal/kibana/security/exception_item/acc_test.go b/internal/kibana/security/exception_item/acc_test.go new file mode 100644 index 000000000..2538e7012 --- /dev/null +++ b/internal/kibana/security/exception_item/acc_test.go @@ -0,0 +1,117 @@ +package exception_item_test + +import ( + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var minExceptionItemAPISupport = version.Must(version.NewVersion("7.9.0")) + +func TestAccResourceExceptionItem(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + Config: testAccResourceExceptionItemCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "item_id", "test-exception-item"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Test exception item for acceptance tests"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "type", "simple"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "namespace_type", "single"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.0", "test"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "id"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "entries"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "created_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "created_by"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + Config: testAccResourceExceptionItemUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item Updated"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Updated description"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.0", "test"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.1", "updated"), + ), + }, + }, + }) +} + +const testAccResourceExceptionItemCreate = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = "test-exception-list-for-item" + name = "Test Exception List for Item" + description = "Test exception list" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = "test-exception-item" + name = "Test Exception Item" + description = "Test exception item for acceptance tests" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "test-process" + } + ]) + + tags = ["test"] +} +` + +const testAccResourceExceptionItemUpdate = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = "test-exception-list-for-item" + name = "Test Exception List for Item" + description = "Test exception list" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = "test-exception-item" + name = "Test Exception Item Updated" + description = "Updated description" + type = "simple" + namespace_type = "single" + + entries = jsonencode([ + { + field = "process.name" + operator = "included" + type = "match" + value = "test-process-updated" + } + ]) + + tags = ["test", "updated"] +} +` diff --git a/internal/kibana/security/exception_list/acc_test.go b/internal/kibana/security/exception_list/acc_test.go new file mode 100644 index 000000000..7d3027b9e --- /dev/null +++ b/internal/kibana/security/exception_list/acc_test.go @@ -0,0 +1,80 @@ +package exception_list_test + +import ( + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var minExceptionListAPISupport = version.Must(version.NewVersion("7.9.0")) + +func TestAccResourceExceptionList(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionListAPISupport), + Config: testAccResourceExceptionListCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "list_id", "test-exception-list"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "name", "Test Exception List"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "description", "Test exception list for acceptance tests"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "type", "detection"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "namespace_type", "single"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "tags.0", "test"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_list.test", "id"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_list.test", "created_at"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_list.test", "created_by"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionListAPISupport), + Config: testAccResourceExceptionListUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "name", "Test Exception List Updated"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "description", "Updated description"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "tags.0", "test"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_list.test", "tags.1", "updated"), + ), + }, + }, + }) +} + +const testAccResourceExceptionListCreate = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = "test-exception-list" + name = "Test Exception List" + description = "Test exception list for acceptance tests" + type = "detection" + namespace_type = "single" + + tags = ["test"] +} +` + +const testAccResourceExceptionListUpdate = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = "test-exception-list" + name = "Test Exception List Updated" + description = "Updated description" + type = "detection" + namespace_type = "single" + + tags = ["test", "updated"] +} +` diff --git a/templates/resources/kibana_security_exception_item.md.tmpl b/templates/resources/kibana_security_exception_item.md.tmpl new file mode 100644 index 000000000..a8ad6b6c0 --- /dev/null +++ b/templates/resources/kibana_security_exception_item.md.tmpl @@ -0,0 +1,49 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.RenderedProviderName}}" +subcategory: "Kibana" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### Basic exception item + +{{ tffile "examples/resources/elasticstack_kibana_security_exception_item/resource.tf" }} + +### Complex exception item with multiple entries + +{{ tffile "examples/resources/elasticstack_kibana_security_exception_item/resource_complex.tf" }} + +{{ .SchemaMarkdown | trimspace }} +{{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }} + +## Import + +Import is supported using the following syntax: +{{- end }} +{{- if .HasImportIdentityConfig }} + +In Terraform v1.12.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `identity` attribute, for example: + +{{tffile .ImportIdentityConfigFile }} + +{{ .IdentitySchemaMarkdown | trimspace }} +{{- end }} +{{- if .HasImportIDConfig }} + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +{{tffile .ImportIDConfigFile }} +{{- end }} +{{- if .HasImport }} + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +{{codefile "shell" .ImportFile }} +{{- end }} diff --git a/templates/resources/kibana_security_exception_list.md.tmpl b/templates/resources/kibana_security_exception_list.md.tmpl new file mode 100644 index 000000000..c6b737823 --- /dev/null +++ b/templates/resources/kibana_security_exception_list.md.tmpl @@ -0,0 +1,49 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.RenderedProviderName}}" +subcategory: "Kibana" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### Basic exception list + +{{ tffile "examples/resources/elasticstack_kibana_security_exception_list/resource.tf" }} + +### Endpoint exception list with OS types + +{{ tffile "examples/resources/elasticstack_kibana_security_exception_list/resource_endpoint.tf" }} + +{{ .SchemaMarkdown | trimspace }} +{{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }} + +## Import + +Import is supported using the following syntax: +{{- end }} +{{- if .HasImportIdentityConfig }} + +In Terraform v1.12.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `identity` attribute, for example: + +{{tffile .ImportIdentityConfigFile }} + +{{ .IdentitySchemaMarkdown | trimspace }} +{{- end }} +{{- if .HasImportIDConfig }} + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +{{tffile .ImportIDConfigFile }} +{{- end }} +{{- if .HasImport }} + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +{{codefile "shell" .ImportFile }} +{{- end }} From 2aa87dbf4e151e093d1326ee510f6c6363745da4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:55:58 +0000 Subject: [PATCH 4/5] Remove version field from exception_list as it's not in API response Co-authored-by: nick-benoit <163016768+nick-benoit@users.noreply.github.com> --- docs/resources/kibana_security_exception_list.md | 1 - internal/kibana/security/exception_list/create.go | 9 --------- internal/kibana/security/exception_list/models.go | 1 - internal/kibana/security/exception_list/schema.go | 4 ---- 4 files changed, 15 deletions(-) diff --git a/docs/resources/kibana_security_exception_list.md b/docs/resources/kibana_security_exception_list.md index 7f9bfe4f9..5e597712f 100644 --- a/docs/resources/kibana_security_exception_list.md +++ b/docs/resources/kibana_security_exception_list.md @@ -95,4 +95,3 @@ resource "elasticstack_kibana_security_exception_list" "endpoint" { - `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 exception list was last updated. - `updated_by` (String) The user who last updated the exception list. -- `version` (Number) The version of the exception list. diff --git a/internal/kibana/security/exception_list/create.go b/internal/kibana/security/exception_list/create.go index 174b80409..d45ce7f8e 100644 --- a/internal/kibana/security/exception_list/create.go +++ b/internal/kibana/security/exception_list/create.go @@ -137,15 +137,6 @@ func (r *ExceptionListResource) updateStateFromAPIResponse(ctx context.Context, model.Immutable = types.BoolValue(apiResp.Immutable) model.TieBreakerID = types.StringValue(apiResp.TieBreakerId) - // Set version if present - if apiResp.UnderscoreVersion != nil { - // Parse version string to int64 - var version int64 - model.Version = types.Int64Value(version) - } else { - model.Version = types.Int64Null() - } - // Set optional os_types if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { osTypes := make([]string, len(*apiResp.OsTypes)) diff --git a/internal/kibana/security/exception_list/models.go b/internal/kibana/security/exception_list/models.go index 68e2d7e7a..a0f8925f0 100644 --- a/internal/kibana/security/exception_list/models.go +++ b/internal/kibana/security/exception_list/models.go @@ -14,7 +14,6 @@ type ExceptionListModel struct { OsTypes types.List `tfsdk:"os_types"` Tags types.List `tfsdk:"tags"` Meta types.String `tfsdk:"meta"` - Version types.Int64 `tfsdk:"version"` CreatedAt types.String `tfsdk:"created_at"` CreatedBy types.String `tfsdk:"created_by"` UpdatedAt types.String `tfsdk:"updated_at"` diff --git a/internal/kibana/security/exception_list/schema.go b/internal/kibana/security/exception_list/schema.go index 16b49997e..041756cb1 100644 --- a/internal/kibana/security/exception_list/schema.go +++ b/internal/kibana/security/exception_list/schema.go @@ -86,10 +86,6 @@ func (r *ExceptionListResource) Schema(_ context.Context, _ resource.SchemaReque MarkdownDescription: "Placeholder for metadata about the list container as JSON string.", Optional: true, }, - "version": schema.Int64Attribute{ - MarkdownDescription: "The version of the exception list.", - Computed: true, - }, "created_at": schema.StringAttribute{ MarkdownDescription: "The timestamp of when the exception list was created.", Computed: true, From c84195fca608ed2d451fac78756d6765d886aba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:59:31 +0000 Subject: [PATCH 5/5] Remove duplicate examples from resource descriptions to fix doc duplication Co-authored-by: nick-benoit <163016768+nick-benoit@users.noreply.github.com> --- .../kibana_security_exception_item.md | 45 ------------------- .../kibana_security_exception_list.md | 25 ----------- .../exception_item/resource-description.md | 24 ---------- .../exception_list/resource-description.md | 14 ------ 4 files changed, 108 deletions(-) diff --git a/docs/resources/kibana_security_exception_item.md b/docs/resources/kibana_security_exception_item.md index 0070bf7e7..4dd0ab786 100644 --- a/docs/resources/kibana_security_exception_item.md +++ b/docs/resources/kibana_security_exception_item.md @@ -5,27 +5,6 @@ subcategory: "Kibana" description: |- Manages a Kibana Exception Item. Exception items define the specific query conditions used to prevent rules from generating alerts. See the Kibana Exceptions API documentation https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api for more details. - Example Usage - - resource "elasticstack_kibana_security_exception_item" "example" { - list_id = elasticstack_kibana_security_exception_list.example.list_id - item_id = "my-exception-item" - name = "My Exception Item" - description = "Exclude specific processes from alerts" - type = "simple" - namespace_type = "single" - - entries = jsonencode([ - { - field = "process.name" - operator = "included" - type = "match" - value = "my-process" - } - ]) - - tags = ["tag1", "tag2"] - } --- # elasticstack_kibana_security_exception_item (Resource) @@ -36,30 +15,6 @@ See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/do ## Example Usage -```terraform -resource "elasticstack_kibana_security_exception_item" "example" { - list_id = elasticstack_kibana_security_exception_list.example.list_id - item_id = "my-exception-item" - name = "My Exception Item" - description = "Exclude specific processes from alerts" - type = "simple" - namespace_type = "single" - - entries = jsonencode([ - { - field = "process.name" - operator = "included" - type = "match" - value = "my-process" - } - ]) - - tags = ["tag1", "tag2"] -} -``` - -## Example Usage - ### Basic exception item ```terraform diff --git a/docs/resources/kibana_security_exception_list.md b/docs/resources/kibana_security_exception_list.md index 5e597712f..c29101c93 100644 --- a/docs/resources/kibana_security_exception_list.md +++ b/docs/resources/kibana_security_exception_list.md @@ -5,17 +5,6 @@ subcategory: "Kibana" description: |- Manages a Kibana Exception List. Exception lists are containers for exception items used to prevent security rules from generating alerts. See the Kibana Exceptions API documentation https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api for more details. - Example Usage - - resource "elasticstack_kibana_security_exception_list" "example" { - list_id = "my-exception-list" - name = "My Exception List" - description = "List of exceptions for security rules" - type = "detection" - namespace_type = "single" - - tags = ["tag1", "tag2"] - } --- # elasticstack_kibana_security_exception_list (Resource) @@ -26,20 +15,6 @@ See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/do ## Example Usage -```terraform -resource "elasticstack_kibana_security_exception_list" "example" { - list_id = "my-exception-list" - name = "My Exception List" - description = "List of exceptions for security rules" - type = "detection" - namespace_type = "single" - - tags = ["tag1", "tag2"] -} -``` - -## Example Usage - ### Basic exception list ```terraform diff --git a/internal/kibana/security/exception_item/resource-description.md b/internal/kibana/security/exception_item/resource-description.md index 8aa5b436a..7ad7c0a47 100644 --- a/internal/kibana/security/exception_item/resource-description.md +++ b/internal/kibana/security/exception_item/resource-description.md @@ -1,27 +1,3 @@ Manages a Kibana Exception Item. Exception items define the specific query conditions used to prevent rules from generating alerts. See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. - -## Example Usage - -```terraform -resource "elasticstack_kibana_security_exception_item" "example" { - list_id = elasticstack_kibana_security_exception_list.example.list_id - item_id = "my-exception-item" - name = "My Exception Item" - description = "Exclude specific processes from alerts" - type = "simple" - namespace_type = "single" - - entries = jsonencode([ - { - field = "process.name" - operator = "included" - type = "match" - value = "my-process" - } - ]) - - tags = ["tag1", "tag2"] -} -``` diff --git a/internal/kibana/security/exception_list/resource-description.md b/internal/kibana/security/exception_list/resource-description.md index 2f3251c68..d773a038b 100644 --- a/internal/kibana/security/exception_list/resource-description.md +++ b/internal/kibana/security/exception_list/resource-description.md @@ -1,17 +1,3 @@ Manages a Kibana Exception List. Exception lists are containers for exception items used to prevent security rules from generating alerts. See the [Kibana Exceptions API documentation](https://www.elastic.co/docs/api/doc/kibana/group/endpoint-security-exceptions-api) for more details. - -## Example Usage - -```terraform -resource "elasticstack_kibana_security_exception_list" "example" { - list_id = "my-exception-list" - name = "My Exception List" - description = "List of exceptions for security rules" - type = "detection" - namespace_type = "single" - - tags = ["tag1", "tag2"] -} -```