diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index c863aa64f..fd09be599 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -36,6 +36,7 @@ This document outlines the coding standards and conventions used in the terrafor - Prefer using existing util functions over longer form, duplicated code: - `utils.IsKnown(val)` instead of `!val.IsNull() && !val.IsUnknown()` - `utils.ListTypeAs` instead of `val.ElementsAs` or similar for other collection types + - `typeutils.StringishValue` instead of casting to a string e.g. `types.StringValue(string(apiResp.Id))`. Use `typeutils.StringishPointerValue` for pointers - The final state for a resource should be derived from a read request following a mutative request (eg create or update). We should not use the response from a mutative request to build the final resource state. ## Schema Definitions @@ -75,6 +76,12 @@ This document outlines the coding standards and conventions used in the terrafor - Define any required variables within the module - Reference the test code via `ConfigDirectory: acctest.NamedTestCaseDirectory("")` - Define any required variables via `ConfigVariables` +- Resources should include tests for the following + - Creating a resource + - Updating a resource + - Deleting a resource + - Importing a resource + - Creating a resource in another space (if applicable) ## API Client Usage 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..fa0184e09 --- /dev/null +++ b/examples/resources/elasticstack_kibana_security_exception_item/resource.tf @@ -0,0 +1,36 @@ +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 = [ + { + type = "match" + field = "host.name" + operator = "included" + value = "trusted-host" + }, + { + type = "match_any" + field = "user.name" + operator = "excluded" + values = ["admin", "root"] + } + ] + + os_types = ["linux"] + tags = ["complex", "multi-condition"] +} + diff --git a/generated/kbapi/kibana.gen.go b/generated/kbapi/kibana.gen.go index 28bad859d..2962bd456 100644 --- a/generated/kbapi/kibana.gen.go +++ b/generated/kbapi/kibana.gen.go @@ -19613,7 +19613,7 @@ type SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem struct { type SecurityExceptionsAPIExceptionListItemEntryOperator string // SecurityExceptionsAPIExceptionListItemExpireTime The exception item’s expiration date, in ISO format. This field is only available for regular exception items, not endpoint exceptions. -type SecurityExceptionsAPIExceptionListItemExpireTime = time.Time +type SecurityExceptionsAPIExceptionListItemExpireTime = string // SecurityExceptionsAPIExceptionListItemHumanId Human readable string identifier, e.g. `trusted-linux-processes` type SecurityExceptionsAPIExceptionListItemHumanId = string @@ -60869,7 +60869,7 @@ func (t SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item) AsSLO // FromSLOsTimesliceMetricBasicMetricWithField overwrites any union data inside the SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item as the provided SLOsTimesliceMetricBasicMetricWithField func (t *SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item) FromSLOsTimesliceMetricBasicMetricWithField(v SLOsTimesliceMetricBasicMetricWithField) error { - v.Aggregation = "last_value" + v.Aggregation = "std_deviation" b, err := json.Marshal(v) t.union = b return err @@ -60877,7 +60877,7 @@ func (t *SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item) From // MergeSLOsTimesliceMetricBasicMetricWithField performs a merge with any union data inside the SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item, using the provided SLOsTimesliceMetricBasicMetricWithField func (t *SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item) MergeSLOsTimesliceMetricBasicMetricWithField(v SLOsTimesliceMetricBasicMetricWithField) error { - v.Aggregation = "last_value" + v.Aggregation = "std_deviation" b, err := json.Marshal(v) if err != nil { return err @@ -60960,10 +60960,10 @@ func (t SLOsIndicatorPropertiesTimesliceMetric_Params_Metric_Metrics_Item) Value switch discriminator { case "doc_count": return t.AsSLOsTimesliceMetricDocCountMetric() - case "last_value": - return t.AsSLOsTimesliceMetricBasicMetricWithField() case "percentile": return t.AsSLOsTimesliceMetricPercentileMetric() + case "std_deviation": + return t.AsSLOsTimesliceMetricBasicMetricWithField() default: return nil, errors.New("unknown discriminator value: " + discriminator) } diff --git a/generated/kbapi/transform_schema.go b/generated/kbapi/transform_schema.go index 05fbf0bca..fffbd270e 100644 --- a/generated/kbapi/transform_schema.go +++ b/generated/kbapi/transform_schema.go @@ -870,6 +870,7 @@ func transformKibanaPaths(schema *Schema) { }, "propertyName": "action_type_id", }) + schema.Components.Delete("schemas.Security_Exceptions_API_ExceptionListItemExpireTime.format") } diff --git a/internal/clients/api_client.go b/internal/clients/api_client.go index 1361aff1b..3ea704f29 100644 --- a/internal/clients/api_client.go +++ b/internal/clients/api_client.go @@ -451,7 +451,9 @@ func (a *ApiClient) flavorFromKibana() (string, diag.Diagnostics) { serverFlavor, ok := vMap["build_flavor"].(string) if !ok { - return "", diag.Errorf("failed to get build flavor from Kibana status") + // build_flavor field is not present in older Kibana versions (pre-serverless) + // Default to empty string to indicate traditional/stateful deployment + return "", nil } return serverFlavor, nil 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..7fa29b10a --- /dev/null +++ b/internal/kibana/security_exception_item/acc_test.go @@ -0,0 +1,816 @@ +package security_exception_item_test + +import ( + "context" + "fmt" + "regexp" + "testing" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" + "github.com/google/uuid" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +var MinVersionExpireTime = version.Must(version.NewVersion("8.7.2")) +var MinExceptionItemVersion = version.Must(version.NewVersion("7.9.0")) + +// https://github.com/elastic/kibana/pull/159223 +// These versions don't respect item_id which breaks most tests +const exceptionItemBugVersionConstraint = "!=8.7.0,!=8.7.1" +const minExceptionItemAPISupportConstraint = ">=7.9.0" + +var allTestsVersionsConstraint, _ = version.NewConstraint(exceptionItemBugVersionConstraint + "," + minExceptionItemAPISupportConstraint) + +func TestAccResourceExceptionItem(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-for-item"), + "item_id": config.StringVariable("test-exception-item"), + "name": config.StringVariable("Test Exception Item"), + "description": config.StringVariable("Test exception item for acceptance tests"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test")), + }, + 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.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-for-item"), + "item_id": config.StringVariable("test-exception-item"), + "name": config.StringVariable("Test Exception Item Updated"), + "description": config.StringVariable("Updated description"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("updated")), + }, + 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"), + ), + }, + { // Import + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-for-item"), + "item_id": config.StringVariable("test-exception-item"), + "name": config.StringVariable("Test Exception Item Updated"), + "description": config.StringVariable("Updated description"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("updated")), + }, + ResourceName: "elasticstack_kibana_security_exception_item.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceExceptionItem_BasicUsage(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(MinExceptionItemVersion), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("basic_create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-basic"), + "name": config.StringVariable("Test Exception Item - Basic"), + "description": config.StringVariable("Test exception item without explicit item_id"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test")), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "item_id"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item - Basic"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Test exception item without explicit item_id"), + 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.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("basic_update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-basic"), + "name": config.StringVariable("Test Exception Item - Basic Updated"), + "description": config.StringVariable("Updated basic exception item"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("updated")), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "item_id"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item - Basic Updated"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Updated basic exception item"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.0", "test"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.1", "updated"), + ), + }, + { // Import + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("basic_update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable("test-exception-list-basic"), + "name": config.StringVariable("Test Exception Item - Basic Updated"), + "description": config.StringVariable("Updated basic exception item"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("updated")), + }, + ResourceName: "elasticstack_kibana_security_exception_item.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceExceptionItemWithSpace(t *testing.T) { + resourceName := "elasticstack_kibana_security_exception_item.test" + spaceResourceName := "elasticstack_kibana_space.test" + spaceID := fmt.Sprintf("test-space-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.Providers, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ConfigDirectory: acctest.NamedTestCaseDirectory("create"), + ConfigVariables: config.Variables{ + "space_id": config.StringVariable(spaceID), + "list_id": config.StringVariable("test-exception-list-for-item-space"), + "item_id": config.StringVariable("test-exception-item-space"), + "name": config.StringVariable("Test Exception Item in Space"), + "description": config.StringVariable("Test exception item in custom space"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("space")), + }, + Check: resource.ComposeTestCheckFunc( + // Check space attributes + resource.TestCheckResourceAttr(spaceResourceName, "space_id", spaceID), + resource.TestCheckResourceAttr(spaceResourceName, "name", "Test Space for Exception Items"), + + // Check exception item attributes + resource.TestCheckResourceAttr(resourceName, "space_id", spaceID), + resource.TestCheckResourceAttr(resourceName, "item_id", "test-exception-item-space"), + resource.TestCheckResourceAttr(resourceName, "name", "Test Exception Item in Space"), + resource.TestCheckResourceAttr(resourceName, "description", "Test exception item in custom space"), + resource.TestCheckResourceAttr(resourceName, "type", "simple"), + resource.TestCheckResourceAttr(resourceName, "namespace_type", "single"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "test"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "space"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "entries.#"), + resource.TestCheckResourceAttrSet(resourceName, "created_at"), + resource.TestCheckResourceAttrSet(resourceName, "created_by"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "space_id": config.StringVariable(spaceID), + "list_id": config.StringVariable("test-exception-list-for-item-space"), + "item_id": config.StringVariable("test-exception-item-space"), + "name": config.StringVariable("Test Exception Item in Space Updated"), + "description": config.StringVariable("Updated description in space"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("space"), config.StringVariable("updated")), + }, + Check: resource.ComposeTestCheckFunc( + // Check space attributes remain the same + resource.TestCheckResourceAttr(spaceResourceName, "space_id", spaceID), + resource.TestCheckResourceAttr(spaceResourceName, "name", "Test Space for Exception Items"), + + // Check updated exception item attributes + resource.TestCheckResourceAttr(resourceName, "space_id", spaceID), + resource.TestCheckResourceAttr(resourceName, "name", "Test Exception Item in Space Updated"), + resource.TestCheckResourceAttr(resourceName, "description", "Updated description in space"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "test"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "space"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "updated"), + ), + }, + { // Import + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ConfigDirectory: acctest.NamedTestCaseDirectory("update"), + ConfigVariables: config.Variables{ + "space_id": config.StringVariable(spaceID), + "list_id": config.StringVariable("test-exception-list-for-item-space"), + "item_id": config.StringVariable("test-exception-item-space"), + "name": config.StringVariable("Test Exception Item in Space Updated"), + "description": config.StringVariable("Updated description in space"), + "type": config.StringVariable("simple"), + "namespace_type": config.StringVariable("single"), + "tags": config.ListVariable(config.StringVariable("test"), config.StringVariable("space"), config.StringVariable("updated")), + }, + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceExceptionItemNamespaceType_Agnostic(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-agnostic-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-agnostic-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "item_id", itemID), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item - Agnostic"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Test exception item with agnostic namespace type"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "type", "simple"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "namespace_type", "agnostic"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "id"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "entries.#"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "name", "Test Exception Item - Agnostic Updated"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "description", "Updated agnostic exception item"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "namespace_type", "agnostic"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.#", "2"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "test"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "updated"), + ), + }, + { // Import + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ResourceName: "elasticstack_kibana_security_exception_item.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_Match(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-match-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-match-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("match"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "match"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "process.name"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.value", "test-process"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_MatchAny(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-match-any-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-match-any-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("match_any"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "match_any"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "process.name"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.values.0", "process1"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.values.1", "process2"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.values.2", "process3"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_List(t *testing.T) { + exceptionListID := fmt.Sprintf("test-exception-list-list-entry-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-list-entry-%s", uuid.New().String()[:8]) + valueListID := fmt.Sprintf("test-value-list-%s", uuid.New().String()[:8]) + valueListValue := "192.168.1.1" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("list"), + ConfigVariables: config.Variables{ + "exception_list_id": config.StringVariable(exceptionListID), + "item_id": config.StringVariable(itemID), + "value_list_id": config.StringVariable(valueListID), + "value_list_value": config.StringVariable(valueListValue), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "list"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "source.ip"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.list.id", valueListID), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.list.type", "ip"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_Exists(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-exists-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-exists-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("exists"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "exists"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "file.hash.sha256"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.operator", "included"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_Nested(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-nested-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-nested-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("nested"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "nested"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "parent.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.type", "match"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.field", "nested.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.value", "nested-value"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("nested_match_any"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "nested"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "parent.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.type", "match_any"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.field", "nested.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.values.0", "value1"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.values.1", "value2"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.values.2", "value3"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("nested_exists"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "nested"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "parent.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.type", "exists"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.field", "nested.field"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.entries.0.operator", "included"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemEntryType_Wildcard(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-wildcard-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-wildcard-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("wildcard"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.type", "wildcard"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.field", "file.path"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.operator", "included"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "entries.0.value", "/tmp/*.tmp"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItemValidation(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-validation-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-validation-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + // Test 1: Match entry missing value + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_missing_value"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'match' requires 'value' to be set"), + PlanOnly: true, + }, + // Test 2: Match entry missing operator + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_missing_operator"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'match' requires 'operator' to be set"), + PlanOnly: true, + }, + // Test 3: Wildcard entry missing value + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_wildcard_missing_value"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'wildcard' requires 'value' to be set"), + PlanOnly: true, + }, + // Test 4: MatchAny entry missing values + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_any_missing_values"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'match_any' requires 'values' to be set"), + PlanOnly: true, + }, + // Test 5: MatchAny entry missing operator + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_any_missing_operator"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'match_any' requires 'operator' to be set"), + PlanOnly: true, + }, + // Test 6: List entry missing list object + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_object"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'list' requires 'list' object to be set"), + PlanOnly: true, + }, + // Test 7: List entry missing list.id + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_id"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile(`attribute "id" is required`), + PlanOnly: true, + }, + // Test 8: List entry missing list.type + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_type"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile(`attribute "type" is required`), + PlanOnly: true, + }, + // Test 9: Exists entry missing operator + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_exists_missing_operator"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'exists' requires 'operator' to be set"), + PlanOnly: true, + }, + // Test 10: Nested entry missing entries + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_missing_entries"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Entry type 'nested' requires 'entries' to be set"), + PlanOnly: true, + }, + // Test 11: Nested entry with invalid entry type + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_invalid_entry_type"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile(`(Nested entry .* has invalid type|value must be one of:.*"match".*"match_any".*"exists")`), + PlanOnly: true, + }, + // Test 12: Nested match entry missing value + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_entry_missing_value"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile("Nested entry type 'match' requires 'value' to be set"), + PlanOnly: true, + }, + // Test 13: Nested entry missing operator + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_entry_missing_operator"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + ExpectError: regexp.MustCompile(`(Nested entry requires 'operator' to be set|attribute "operator" is required)`), + PlanOnly: true, + }, + }, + }) +} + +func TestAccResourceExceptionItem_Complex(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-complex-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-complex-%s", uuid.New().String()[:8]) + + // Generate an expiration time 2 days from now with milliseconds set to 0 + // since default go time formatting may truncate milliseconds in date serialization + // resulting in 4xx responses from Kibana + expireTime := time.Now().AddDate(0, 0, 2).UTC().Truncate(24 * time.Hour).Format("2006-01-02T15:04:05.000Z") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("complex_create"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "os_types.#", "2"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "linux"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "macos"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.#", "2"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "test"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "complex"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("complex_update"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "os_types.#", "3"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "linux"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "macos"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "windows"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.#", "3"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "test"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "complex"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "updated"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(MinVersionExpireTime), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("complex_update_expire_time"), + ConfigVariables: config.Variables{ + "list_id": config.StringVariable(listID), + "item_id": config.StringVariable(itemID), + "expire_time": config.StringVariable(expireTime), + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "os_types.#", "3"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "linux"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "macos"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "os_types.*", "windows"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.#", "3"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "test"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "complex"), + resource.TestCheckTypeSetElemAttr("elasticstack_kibana_security_exception_item.test", "tags.*", "updated"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "expire_time", expireTime), + ), + }, + }, + }) +} + +func checkResourceExceptionItemDestroy(s *terraform.State) error { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + return err + } + + oapiClient, err := client.GetKibanaOapiClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "elasticstack_kibana_security_exception_item" { + continue + } + + compId, compDiags := clients.CompositeIdFromStr(rs.Primary.ID) + if compDiags.HasError() { + return diagutil.SdkDiagsAsError(compDiags) + } + + // Try to read the exception item + id := kbapi.SecurityExceptionsAPIExceptionListItemId(compId.ResourceId) + params := &kbapi.ReadExceptionListItemParams{ + Id: &id, + } + + // If namespace_type is available in the state, use it + if nsType, ok := rs.Primary.Attributes["namespace_type"]; ok && nsType != "" { + nsTypeVal := kbapi.SecurityExceptionsAPIExceptionNamespaceType(nsType) + params.NamespaceType = &nsTypeVal + } + + item, diags := kibana_oapi.GetExceptionListItem(context.Background(), oapiClient, compId.ClusterId, params) + if diags.HasError() { + // If we get an error, it might be that the resource doesn't exist, which is what we want + continue + } + + if item != nil { + return fmt.Errorf("Exception item (%s) still exists in space (%s)", compId.ResourceId, compId.ClusterId) + } + } + return nil +} diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go new file mode 100644 index 000000000..92504c12c --- /dev/null +++ b/internal/kibana/security_exception_item/create.go @@ -0,0 +1,80 @@ +package security_exception_item + +import ( + "context" + + "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) 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 + } + + // Build the request body using model method + body, diags := plan.toCreateRequest(ctx, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Create the exception item + createResp, diags := kibana_oapi.CreateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), *body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil { + resp.Diagnostics.AddError("Failed to create exception item", "API returned empty response") + return + } + + // In create/update paths we typically follow the write operation with a read, and then set the state from the read. + // We want to avoid a dirty plan immediately after an apply. + + // Read back the created resource to get the final state + readParams := &kbapi.ReadExceptionListItemParams{ + Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&createResp.Id), + } + + // Include namespace_type if specified (required for agnostic items) + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + readParams.NamespaceType = &nsType + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response using model method + diags = plan.fromAPI(ctx, readResp) + 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_item/delete.go b/internal/kibana/security_exception_item/delete.go new file mode 100644 index 000000000..c8fca9445 --- /dev/null +++ b/internal/kibana/security_exception_item/delete.go @@ -0,0 +1,42 @@ +package security_exception_item + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "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 + } + + // Parse composite ID to get space_id and resource_id + compId, compIdDiags := clients.CompositeIdFromStrFw(state.ID.ValueString()) + resp.Diagnostics.Append(compIdDiags...) + 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(compId.ResourceId) + params := &kbapi.DeleteExceptionListItemParams{ + Id: &id, + } + + diags = kibana_oapi.DeleteExceptionListItem(ctx, client, state.SpaceID.ValueString(), 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..d3a7dc415 --- /dev/null +++ b/internal/kibana/security_exception_item/models.go @@ -0,0 +1,1045 @@ +package security_exception_item + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/typeutils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// MinVersionExpireTime defines the minimum server version required for expire_time field +var MinVersionExpireTime = version.Must(version.NewVersion("8.7.2")) + +type ExceptionItemModel struct { + ID types.String `tfsdk:"id"` + SpaceID types.String `tfsdk:"space_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.Set `tfsdk:"os_types"` + Tags types.Set `tfsdk:"tags"` + Meta jsontypes.Normalized `tfsdk:"meta"` + Entries types.List `tfsdk:"entries"` + Comments types.List `tfsdk:"comments"` + ExpireTime timetypes.RFC3339 `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"` +} + +type EntryModel struct { + Type types.String `tfsdk:"type"` + Field types.String `tfsdk:"field"` + Operator types.String `tfsdk:"operator"` + Value types.String `tfsdk:"value"` + Values types.List `tfsdk:"values"` + List types.Object `tfsdk:"list"` + Entries types.List `tfsdk:"entries"` +} + +type EntryListModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` +} + +type NestedEntryModel struct { + Type types.String `tfsdk:"type"` + Field types.String `tfsdk:"field"` + Operator types.String `tfsdk:"operator"` + Value types.String `tfsdk:"value"` + Values types.List `tfsdk:"values"` +} + +// convertEntriesToAPI converts Terraform entry models to API entry models +func convertEntriesToAPI(ctx context.Context, entries types.List) (kbapi.SecurityExceptionsAPIExceptionListItemEntryArray, diag.Diagnostics) { + var diags diag.Diagnostics + + if !utils.IsKnown(entries) { + return nil, diags + } + + entryModels := utils.ListTypeAs[EntryModel](ctx, entries, path.Empty(), &diags) + if diags.HasError() { + return nil, diags + } + + apiEntries := make(kbapi.SecurityExceptionsAPIExceptionListItemEntryArray, 0, len(entryModels)) + for _, entry := range entryModels { + apiEntry, d := convertEntryToAPI(ctx, entry) + diags.Append(d...) + if d.HasError() { + continue + } + apiEntries = append(apiEntries, apiEntry) + } + + return apiEntries, diags +} + +// convertMatchEntryToAPI converts a match entry to API format +func convertMatchEntryToAPI(entry EntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + // Validate required field + if !utils.IsKnown(entry.Value) || entry.Value.ValueString() == "" { + diags.AddError("Invalid Configuration", "Attribute 'value' is required when type is 'match'") + return result, diags + } + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryMatch{ + Type: "match", + Field: field, + Operator: operator, + Value: kbapi.SecurityExceptionsAPINonEmptyString(entry.Value.ValueString()), + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryMatch(apiEntry); err != nil { + diags.AddError("Failed to create match entry", err.Error()) + } + + return result, diags +} + +// convertMatchAnyEntryToAPI converts a match_any entry to API format +func convertMatchAnyEntryToAPI(ctx context.Context, entry EntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + // Validate required field + if !utils.IsKnown(entry.Values) { + diags.AddError("Invalid Configuration", "Attribute 'values' is required when type is 'match_any'") + return result, diags + } + + values := utils.ListTypeAs[string](ctx, entry.Values, path.Empty(), &diags) + if diags.HasError() { + return result, diags + } + + if len(values) == 0 { + diags.AddError("Invalid Configuration", "Attribute 'values' must contain at least one value when type is 'match_any'") + return result, diags + } + + apiValues := make([]kbapi.SecurityExceptionsAPINonEmptyString, len(values)) + for i, v := range values { + apiValues[i] = kbapi.SecurityExceptionsAPINonEmptyString(v) + } + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryMatchAny{ + Type: "match_any", + Field: field, + Operator: operator, + Value: apiValues, + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryMatchAny(apiEntry); err != nil { + diags.AddError("Failed to create match_any entry", err.Error()) + } + + return result, diags +} + +// convertListEntryToAPI converts a list entry to API format +func convertListEntryToAPI(ctx context.Context, entry EntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + // Validate required field + if !utils.IsKnown(entry.List) { + diags.AddError("Invalid Configuration", "Attribute 'list' is required when type is 'list'") + return result, diags + } + + var listModel EntryListModel + diags.Append(entry.List.As(ctx, &listModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return result, diags + } + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryList{ + Type: "list", + Field: field, + Operator: operator, + } + apiEntry.List.Id = kbapi.SecurityExceptionsAPIListId(listModel.ID.ValueString()) + apiEntry.List.Type = kbapi.SecurityExceptionsAPIListType(listModel.Type.ValueString()) + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryList(apiEntry); err != nil { + diags.AddError("Failed to create list entry", err.Error()) + } + + return result, diags +} + +// convertExistsEntryToAPI converts an exists entry to API format +func convertExistsEntryToAPI(field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryExists{ + Type: "exists", + Field: field, + Operator: operator, + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryExists(apiEntry); err != nil { + diags.AddError("Failed to create exists entry", err.Error()) + } + + return result, diags +} + +// convertWildcardEntryToAPI converts a wildcard entry to API format +func convertWildcardEntryToAPI(entry EntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + // Validate required field + if !utils.IsKnown(entry.Value) || entry.Value.ValueString() == "" { + diags.AddError("Invalid Configuration", "Attribute 'value' is required when type is 'wildcard'") + return result, diags + } + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryMatchWildcard{ + Type: "wildcard", + Field: field, + Operator: operator, + Value: kbapi.SecurityExceptionsAPINonEmptyString(entry.Value.ValueString()), + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryMatchWildcard(apiEntry); err != nil { + diags.AddError("Failed to create wildcard entry", err.Error()) + } + + return result, diags +} + +// convertNestedEntryArrayToAPI converts nested entries to API format +func convertNestedEntryArrayToAPI(ctx context.Context, entry EntryModel, field kbapi.SecurityExceptionsAPINonEmptyString) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + // Validate required field + if !utils.IsKnown(entry.Entries) { + diags.AddError("Invalid Configuration", "Attribute 'entries' is required when type is 'nested'") + return result, diags + } + + nestedEntries := utils.ListTypeAs[NestedEntryModel](ctx, entry.Entries, path.Empty(), &diags) + if diags.HasError() { + return result, diags + } + + if len(nestedEntries) == 0 { + diags.AddError("Invalid Configuration", "Attribute 'entries' must contain at least one entry when type is 'nested'") + return result, diags + } + + apiNestedEntries := make([]kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem, 0, len(nestedEntries)) + for _, ne := range nestedEntries { + nestedAPIEntry, d := convertNestedEntryToAPI(ctx, ne) + diags.Append(d...) + if d.HasError() { + continue + } + apiNestedEntries = append(apiNestedEntries, nestedAPIEntry) + } + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryNested{ + Type: "nested", + Field: field, + Entries: apiNestedEntries, + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryNested(apiEntry); err != nil { + diags.AddError("Failed to create nested entry", err.Error()) + } + + return result, diags +} + +// convertEntryToAPI converts a single Terraform entry model to an API entry model +func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExceptionsAPIExceptionListItemEntry, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntry + + entryType := entry.Type.ValueString() + operator := kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator(entry.Operator.ValueString()) + field := kbapi.SecurityExceptionsAPINonEmptyString(entry.Field.ValueString()) + + switch entryType { + case "match": + return convertMatchEntryToAPI(entry, field, operator) + case "match_any": + return convertMatchAnyEntryToAPI(ctx, entry, field, operator) + case "list": + return convertListEntryToAPI(ctx, entry, field, operator) + case "exists": + return convertExistsEntryToAPI(field, operator) + case "wildcard": + return convertWildcardEntryToAPI(entry, field, operator) + case "nested": + return convertNestedEntryArrayToAPI(ctx, entry, field) + default: + diags.AddError("Invalid entry type", fmt.Sprintf("Unknown entry type: %s", entryType)) + return result, diags + } +} + +// convertNestedMatchEntryToAPI converts a nested match entry to API format +func convertNestedMatchEntryToAPI(entry NestedEntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem + + // Validate required field + if !utils.IsKnown(entry.Value) || entry.Value.ValueString() == "" { + diags.AddError("Invalid Configuration", "Attribute 'value' is required for nested entry when type is 'match'") + return result, diags + } + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryMatch{ + Type: "match", + Field: field, + Operator: operator, + Value: kbapi.SecurityExceptionsAPINonEmptyString(entry.Value.ValueString()), + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryMatch(apiEntry); err != nil { + diags.AddError("Failed to create nested match entry", err.Error()) + } + + return result, diags +} + +// convertNestedMatchAnyEntryToAPI converts a nested match_any entry to API format +func convertNestedMatchAnyEntryToAPI(ctx context.Context, entry NestedEntryModel, field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem + + // Validate required field + if !utils.IsKnown(entry.Values) { + diags.AddError("Invalid Configuration", "Attribute 'values' is required for nested entry when type is 'match_any'") + return result, diags + } + + values := utils.ListTypeAs[string](ctx, entry.Values, path.Empty(), &diags) + if diags.HasError() { + return result, diags + } + + if len(values) == 0 { + diags.AddError("Invalid Configuration", "Attribute 'values' must contain at least one value for nested entry when type is 'match_any'") + return result, diags + } + + apiValues := make([]kbapi.SecurityExceptionsAPINonEmptyString, len(values)) + for i, v := range values { + apiValues[i] = kbapi.SecurityExceptionsAPINonEmptyString(v) + } + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryMatchAny{ + Type: "match_any", + Field: field, + Operator: operator, + Value: apiValues, + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryMatchAny(apiEntry); err != nil { + diags.AddError("Failed to create nested match_any entry", err.Error()) + } + + return result, diags +} + +// convertNestedExistsEntryToAPI converts a nested exists entry to API format +func convertNestedExistsEntryToAPI(field kbapi.SecurityExceptionsAPINonEmptyString, operator kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator) (kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem + + apiEntry := kbapi.SecurityExceptionsAPIExceptionListItemEntryExists{ + Type: "exists", + Field: field, + Operator: operator, + } + if err := result.FromSecurityExceptionsAPIExceptionListItemEntryExists(apiEntry); err != nil { + diags.AddError("Failed to create nested exists entry", err.Error()) + } + + return result, diags +} + +// convertNestedEntryToAPI converts a nested entry model to an API nested entry model +func convertNestedEntryToAPI(ctx context.Context, entry NestedEntryModel) (kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem, diag.Diagnostics) { + var diags diag.Diagnostics + var result kbapi.SecurityExceptionsAPIExceptionListItemEntryNestedEntryItem + + entryType := entry.Type.ValueString() + operator := kbapi.SecurityExceptionsAPIExceptionListItemEntryOperator(entry.Operator.ValueString()) + field := kbapi.SecurityExceptionsAPINonEmptyString(entry.Field.ValueString()) + + switch entryType { + case "match": + return convertNestedMatchEntryToAPI(entry, field, operator) + case "match_any": + return convertNestedMatchAnyEntryToAPI(ctx, entry, field, operator) + case "exists": + return convertNestedExistsEntryToAPI(field, operator) + default: + diags.AddError("Invalid nested entry type", fmt.Sprintf("Unknown nested entry type: %s. Only 'match', 'match_any', and 'exists' are allowed.", entryType)) + return result, diags + } +} + +// convertEntriesFromAPI converts API entry models to Terraform entry models +func convertEntriesFromAPI(ctx context.Context, apiEntries kbapi.SecurityExceptionsAPIExceptionListItemEntryArray) (types.List, diag.Diagnostics) { + var diags diag.Diagnostics + + if len(apiEntries) == 0 { + return types.ListNull(types.ObjectType{ + AttrTypes: getEntryAttrTypes(), + }), diags + } + + entries := make([]EntryModel, 0, len(apiEntries)) + for _, apiEntry := range apiEntries { + entry, d := convertEntryFromAPI(ctx, apiEntry) + diags.Append(d...) + if d.HasError() { + continue + } + entries = append(entries, entry) + } + + list, d := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: getEntryAttrTypes(), + }, entries) + diags.Append(d...) + return list, diags +} + +// convertMatchOrWildcardEntryFromAPI converts match or wildcard entries from API format +func convertMatchOrWildcardEntryFromAPI(entryMap map[string]interface{}, entry *EntryModel) { + if value, ok := entryMap["value"].(string); ok { + entry.Value = types.StringValue(value) + } else { + entry.Value = types.StringNull() + } + entry.Values = types.ListNull(types.StringType) + entry.List = types.ObjectNull(getListAttrTypes()) + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) +} + +// convertMatchAnyEntryFromAPI converts match_any entries from API format +func convertMatchAnyEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { + var diags diag.Diagnostics + + if values, ok := entryMap["value"].([]interface{}); ok { + strValues := make([]string, 0, len(values)) + for _, v := range values { + if str, ok := v.(string); ok { + strValues = append(strValues, str) + } + } + list, d := types.ListValueFrom(ctx, types.StringType, strValues) + diags.Append(d...) + entry.Values = list + } else { + entry.Values = types.ListNull(types.StringType) + } + entry.Value = types.StringNull() + entry.List = types.ObjectNull(getListAttrTypes()) + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) + return diags +} + +// convertListEntryFromAPI converts list entries from API format +func convertListEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { + var diags diag.Diagnostics + + if listData, ok := entryMap["list"].(map[string]interface{}); ok { + listModel := EntryListModel{ + ID: types.StringValue(listData["id"].(string)), + Type: types.StringValue(listData["type"].(string)), + } + obj, d := types.ObjectValueFrom(ctx, getListAttrTypes(), listModel) + diags.Append(d...) + entry.List = obj + } else { + entry.List = types.ObjectNull(getListAttrTypes()) + } + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) + return diags +} + +// convertExistsEntryFromAPI converts exists entries from API format +func convertExistsEntryFromAPI(entry *EntryModel) { + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + entry.List = types.ObjectNull(getListAttrTypes()) + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) +} + +// convertNestedEntryFromAPI converts nested entries from API format +func convertNestedEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { + var diags diag.Diagnostics + + // Nested entries don't have an operator field in the API + entry.Operator = types.StringNull() + if entriesData, ok := entryMap["entries"].([]interface{}); ok { + nestedEntries := make([]NestedEntryModel, 0, len(entriesData)) + for _, neData := range entriesData { + if neMap, ok := neData.(map[string]interface{}); ok { + ne, d := convertNestedEntryFromMap(ctx, neMap) + diags.Append(d...) + if !d.HasError() { + nestedEntries = append(nestedEntries, ne) + } + } + } + list, d := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}, nestedEntries) + diags.Append(d...) + entry.Entries = list + } else { + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) + } + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + entry.List = types.ObjectNull(getListAttrTypes()) + return diags +} + +// convertEntryFromAPI converts a single API entry to a Terraform entry model +func convertEntryFromAPI(ctx context.Context, apiEntry kbapi.SecurityExceptionsAPIExceptionListItemEntry) (EntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry EntryModel + + // Marshal the entry back to JSON to inspect its type + entryBytes, err := apiEntry.MarshalJSON() + if err != nil { + diags.AddError("Failed to marshal entry", err.Error()) + return entry, diags + } + + // Try to unmarshal into a map to determine the type + var entryMap map[string]interface{} + if err := json.Unmarshal(entryBytes, &entryMap); err != nil { + diags.AddError("Failed to unmarshal entry", err.Error()) + return entry, diags + } + + entryType, ok := entryMap["type"].(string) + if !ok { + diags.AddError("Invalid entry", "Entry is missing 'type' field") + return entry, diags + } + + entry.Type = types.StringValue(entryType) + if field, ok := entryMap["field"].(string); ok { + entry.Field = types.StringValue(field) + } + if operator, ok := entryMap["operator"].(string); ok { + entry.Operator = types.StringValue(operator) + } + + switch entryType { + case "match", "wildcard": + convertMatchOrWildcardEntryFromAPI(entryMap, &entry) + case "match_any": + d := convertMatchAnyEntryFromAPI(ctx, entryMap, &entry) + diags.Append(d...) + case "list": + d := convertListEntryFromAPI(ctx, entryMap, &entry) + diags.Append(d...) + case "exists": + convertExistsEntryFromAPI(&entry) + case "nested": + d := convertNestedEntryFromAPI(ctx, entryMap, &entry) + diags.Append(d...) + } + + return entry, diags +} + +// convertNestedMatchFromMap converts nested match entries from map format +func convertNestedMatchFromMap(entryMap map[string]interface{}, entry *NestedEntryModel) { + if value, ok := entryMap["value"].(string); ok { + entry.Value = types.StringValue(value) + } else { + entry.Value = types.StringNull() + } + entry.Values = types.ListNull(types.StringType) +} + +// convertNestedMatchAnyFromMap converts nested match_any entries from map format +func convertNestedMatchAnyFromMap(ctx context.Context, entryMap map[string]interface{}, entry *NestedEntryModel) diag.Diagnostics { + var diags diag.Diagnostics + + if values, ok := entryMap["value"].([]interface{}); ok { + strValues := make([]string, 0, len(values)) + for _, v := range values { + if str, ok := v.(string); ok { + strValues = append(strValues, str) + } + } + list, d := types.ListValueFrom(ctx, types.StringType, strValues) + diags.Append(d...) + entry.Values = list + } else { + entry.Values = types.ListNull(types.StringType) + } + entry.Value = types.StringNull() + return diags +} + +// convertNestedExistsFromMap converts nested exists entries from map format +func convertNestedExistsFromMap(entry *NestedEntryModel) { + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) +} + +// convertNestedEntryFromMap converts a map representation of nested entry to a model +func convertNestedEntryFromMap(ctx context.Context, entryMap map[string]interface{}) (NestedEntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry NestedEntryModel + + if entryType, ok := entryMap["type"].(string); ok { + entry.Type = types.StringValue(entryType) + } + if field, ok := entryMap["field"].(string); ok { + entry.Field = types.StringValue(field) + } + if operator, ok := entryMap["operator"].(string); ok { + entry.Operator = types.StringValue(operator) + } + + entryType := entry.Type.ValueString() + switch entryType { + case "match": + convertNestedMatchFromMap(entryMap, &entry) + case "match_any": + d := convertNestedMatchAnyFromMap(ctx, entryMap, &entry) + diags.Append(d...) + case "exists": + convertNestedExistsFromMap(&entry) + } + + return entry, diags +} + +// getEntryAttrTypes returns the attribute types for entry objects +func getEntryAttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "type": types.StringType, + "field": types.StringType, + "operator": types.StringType, + "value": types.StringType, + "values": types.ListType{ElemType: types.StringType}, + "list": types.ObjectType{AttrTypes: getListAttrTypes()}, + "entries": types.ListType{ElemType: types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}}, + } +} + +// getListAttrTypes returns the attribute types for list objects +func getListAttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.StringType, + "type": types.StringType, + } +} + +// getNestedEntryAttrTypes returns the attribute types for nested entry objects +func getNestedEntryAttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "type": types.StringType, + "field": types.StringType, + "operator": types.StringType, + "value": types.StringType, + "values": types.ListType{ElemType: types.StringType}, + } +} + +// getCommentAttrTypes returns the attribute types for comment objects +func getCommentAttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + } +} + +// convertEntriesToAPIWithDiags converts entries and handles diagnostics +func (m *ExceptionItemModel) convertEntriesToAPIWithDiags(ctx context.Context, diags *diag.Diagnostics) kbapi.SecurityExceptionsAPIExceptionListItemEntryArray { + entries, d := convertEntriesToAPI(ctx, m.Entries) + diags.Append(d...) + return entries +} + +// CommonExceptionItemProps holds pointers to common fields across create/update requests +type CommonExceptionItemProps struct { + NamespaceType *kbapi.SecurityExceptionsAPIExceptionNamespaceType + OsTypes *[]kbapi.SecurityExceptionsAPIExceptionListOsType + Tags *kbapi.SecurityExceptionsAPIExceptionListItemTags + Meta *kbapi.SecurityExceptionsAPIExceptionListItemMeta + ExpireTime *kbapi.SecurityExceptionsAPIExceptionListItemExpireTime +} + +// setCommonProps sets common fields across create and update requests +func (m *ExceptionItemModel) setCommonProps( + ctx context.Context, + props *CommonExceptionItemProps, + diags *diag.Diagnostics, + client clients.MinVersionEnforceable, +) { + // Set optional namespace_type + if utils.IsKnown(m.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(m.NamespaceType.ValueString()) + *props.NamespaceType = nsType + } + + // Set optional os_types + if utils.IsKnown(m.OsTypes) { + osTypes := utils.SetTypeAs[kbapi.SecurityExceptionsAPIExceptionListOsType](ctx, m.OsTypes, path.Empty(), diags) + if diags.HasError() { + return + } + if len(osTypes) > 0 { + *props.OsTypes = osTypes + } + } + + // Set optional tags + if utils.IsKnown(m.Tags) { + tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), diags) + if diags.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + *props.Tags = tagsArray + } + } + + // Set optional meta + if utils.IsKnown(m.Meta) { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + unmarshalDiags := m.Meta.Unmarshal(&meta) + diags.Append(unmarshalDiags...) + if diags.HasError() { + return + } + *props.Meta = meta + } + + // Set optional expire_time + if utils.IsKnown(m.ExpireTime) { + // Check version support for expire_time + if supported, versionDiags := client.EnforceMinVersion(ctx, MinVersionExpireTime); versionDiags.HasError() { + diags.Append(diagutil.FrameworkDiagsFromSDK(versionDiags)...) + return + } else if !supported { + diags.AddError("expire_time is unsupported", + fmt.Sprintf("expire_time requires server version %s or higher", MinVersionExpireTime.String())) + return + } + + expireTime, d := m.ExpireTime.ValueRFC3339Time() + diags.Append(d...) + if diags.HasError() { + return + } + + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime.Format("2006-01-02T15:04:05.000Z")) + *props.ExpireTime = expireTimeAPI + } +} + +// commentsToCreateAPI converts comments to create API format +func (m *ExceptionItemModel) commentsToCreateAPI( + ctx context.Context, + diags *diag.Diagnostics, +) *kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray { + if !utils.IsKnown(m.Comments) { + return nil + } + + comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), diags) + if diags.HasError() || len(comments) == 0 { + return nil + } + + commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + return &commentsArray +} + +// commentsToUpdateAPI converts comments to update API format +func (m *ExceptionItemModel) commentsToUpdateAPI( + ctx context.Context, + diags *diag.Diagnostics, +) *kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray { + if !utils.IsKnown(m.Comments) { + return nil + } + + comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), diags) + if diags.HasError() || len(comments) == 0 { + return nil + } + + commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + return &commentsArray +} + +// toCreateRequest converts the Terraform model to API create request +func (m *ExceptionItemModel) toCreateRequest(ctx context.Context, client clients.MinVersionEnforceable) (*kbapi.CreateExceptionListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + // Convert entries from Terraform model to API model + entries := m.convertEntriesToAPIWithDiags(ctx, &diags) + if diags.HasError() { + return nil, diags + } + + req := &kbapi.CreateExceptionListItemJSONRequestBody{ + ListId: kbapi.SecurityExceptionsAPIExceptionListHumanId(m.ListID.ValueString()), + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(m.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(m.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(m.Type.ValueString()), + Entries: entries, + } + + // Set optional item_id + if utils.IsKnown(m.ItemID) { + itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(m.ItemID.ValueString()) + req.ItemId = &itemID + } + + // Set common properties + var nsType kbapi.SecurityExceptionsAPIExceptionNamespaceType + var osTypes []kbapi.SecurityExceptionsAPIExceptionListOsType + var tags kbapi.SecurityExceptionsAPIExceptionListItemTags + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + var expireTime kbapi.SecurityExceptionsAPIExceptionListItemExpireTime + + m.setCommonProps(ctx, &CommonExceptionItemProps{ + NamespaceType: &nsType, + OsTypes: &osTypes, + Tags: &tags, + Meta: &meta, + ExpireTime: &expireTime, + }, &diags, client) + if diags.HasError() { + return nil, diags + } + + // Assign common properties to request if they were set + if utils.IsKnown(m.NamespaceType) { + req.NamespaceType = &nsType + } + if utils.IsKnown(m.OsTypes) && len(osTypes) > 0 { + req.OsTypes = &osTypes + } + if utils.IsKnown(m.Tags) && len(tags) > 0 { + req.Tags = &tags + } + if utils.IsKnown(m.Meta) { + req.Meta = &meta + } + if utils.IsKnown(m.ExpireTime) { + req.ExpireTime = &expireTime + } + + // Set optional comments + if comments := m.commentsToCreateAPI(ctx, &diags); comments != nil { + req.Comments = comments + } + if diags.HasError() { + return nil, diags + } + + return req, diags +} + +// toUpdateRequest converts the Terraform model to API update request +func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId string, client clients.MinVersionEnforceable) (*kbapi.UpdateExceptionListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + // Convert entries from Terraform model to API model + entries := m.convertEntriesToAPIWithDiags(ctx, &diags) + if diags.HasError() { + return nil, diags + } + + id := kbapi.SecurityExceptionsAPIExceptionListItemId(resourceId) + req := &kbapi.UpdateExceptionListItemJSONRequestBody{ + Id: &id, + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(m.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(m.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(m.Type.ValueString()), + Entries: entries, + } + + // Set common properties + var nsType kbapi.SecurityExceptionsAPIExceptionNamespaceType + var osTypes []kbapi.SecurityExceptionsAPIExceptionListOsType + var tags kbapi.SecurityExceptionsAPIExceptionListItemTags + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + var expireTime kbapi.SecurityExceptionsAPIExceptionListItemExpireTime + + m.setCommonProps(ctx, &CommonExceptionItemProps{ + NamespaceType: &nsType, + OsTypes: &osTypes, + Tags: &tags, + Meta: &meta, + ExpireTime: &expireTime, + }, &diags, client) + if diags.HasError() { + return nil, diags + } + + // Assign common properties to request if they were set + if utils.IsKnown(m.NamespaceType) { + req.NamespaceType = &nsType + } + if utils.IsKnown(m.OsTypes) && len(osTypes) > 0 { + req.OsTypes = &osTypes + } + if utils.IsKnown(m.Tags) && len(tags) > 0 { + req.Tags = &tags + } + if utils.IsKnown(m.Meta) { + req.Meta = &meta + } + if utils.IsKnown(m.ExpireTime) { + req.ExpireTime = &expireTime + } + + // Set optional comments + if comments := m.commentsToUpdateAPI(ctx, &diags); comments != nil { + req.Comments = comments + } + if diags.HasError() { + return nil, diags + } + + return req, diags +} + +// fromAPI converts the API response to Terraform model +func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { + var diags diag.Diagnostics + + // Create composite ID from space_id and item id + compId := clients.CompositeId{ + ClusterId: m.SpaceID.ValueString(), + ResourceId: typeutils.StringishValue(apiResp.Id).ValueString(), + } + m.ID = types.StringValue(compId.String()) + + m.ItemID = typeutils.StringishValue(apiResp.ItemId) + m.ListID = typeutils.StringishValue(apiResp.ListId) + m.Name = typeutils.StringishValue(apiResp.Name) + m.Description = typeutils.StringishValue(apiResp.Description) + m.Type = typeutils.StringishValue(apiResp.Type) + m.NamespaceType = typeutils.StringishValue(apiResp.NamespaceType) + m.CreatedAt = types.StringValue(apiResp.CreatedAt.Format("2006-01-02T15:04:05.000Z")) + m.CreatedBy = types.StringValue(apiResp.CreatedBy) + m.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) + m.UpdatedBy = types.StringValue(apiResp.UpdatedBy) + m.TieBreakerID = types.StringValue(apiResp.TieBreakerId) + + // Set optional expire_time + if apiResp.ExpireTime != nil { + expireTime, err := time.Parse(time.RFC3339, string(*apiResp.ExpireTime)) + if err != nil { + diags.AddError("Failed to parse expire_time from API response", err.Error()) + m.ExpireTime = timetypes.NewRFC3339Null() + } else { + m.ExpireTime = timetypes.NewRFC3339TimeValue(expireTime) + } + } else { + m.ExpireTime = timetypes.NewRFC3339Null() + } + + // Set optional os_types + if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { + set, d := types.SetValueFrom(ctx, types.StringType, *apiResp.OsTypes) + diags.Append(d...) + m.OsTypes = set + } else { + m.OsTypes = types.SetNull(types.StringType) + } + + // Set optional tags + if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { + set, d := types.SetValueFrom(ctx, types.StringType, *apiResp.Tags) + diags.Append(d...) + m.Tags = set + } else { + m.Tags = types.SetNull(types.StringType) + } + + // Set optional meta + if apiResp.Meta != nil { + metaBytes, err := json.Marshal(apiResp.Meta) + if err != nil { + diags.AddError("Failed to marshal meta field from API response to JSON", err.Error()) + return diags + } + m.Meta = jsontypes.NewNormalizedValue(string(metaBytes)) + } else { + m.Meta = jsontypes.NewNormalizedNull() + } + + // Set entries (convert from API model to Terraform model) + entriesList, d := convertEntriesFromAPI(ctx, apiResp.Entries) + diags.Append(d...) + m.Entries = entriesList + + // Set optional comments + if len(apiResp.Comments) > 0 { + comments := make([]CommentModel, len(apiResp.Comments)) + for i, comment := range apiResp.Comments { + comments[i] = CommentModel{ + ID: typeutils.StringishValue(comment.Id), + Comment: typeutils.StringishValue(comment.Comment), + } + } + list, d := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: getCommentAttrTypes(), + }, comments) + diags.Append(d...) + m.Comments = list + } else { + m.Comments = types.ListNull(types.ObjectType{ + AttrTypes: getCommentAttrTypes(), + }) + } + + return diags +} diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go new file mode 100644 index 000000000..7f35b8f23 --- /dev/null +++ b/internal/kibana/security_exception_item/read.go @@ -0,0 +1,81 @@ +package security_exception_item + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "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" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +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 + } + + // Parse composite ID to get space_id and resource_id + compId, compIdDiags := clients.CompositeIdFromStrFw(state.ID.ValueString()) + resp.Diagnostics.Append(compIdDiags...) + if resp.Diagnostics.HasError() { + return + } + state.SpaceID = types.StringValue(compId.ClusterId) + + 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(compId.ResourceId) + params := &kbapi.ReadExceptionListItemParams{ + Id: &id, + } + + // Include namespace_type if specified (required for agnostic items) + if utils.IsKnown(state.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(state.NamespaceType.ValueString()) + params.NamespaceType = &nsType + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, state.SpaceID.ValueString(), params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // If namespace_type was not known (e.g., during import) and the item was not found, + // try reading with namespace_type=agnostic + if readResp == nil && !utils.IsKnown(state.NamespaceType) { + agnosticNsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType("agnostic") + params.NamespaceType = &agnosticNsType + readResp, diags = kibana_oapi.GetExceptionListItem(ctx, client, state.SpaceID.ValueString(), params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + if readResp == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with response using model method + diags = state.fromAPI(ctx, readResp) + 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..7ad7c0a47 --- /dev/null +++ b/internal/kibana/security_exception_item/resource-description.md @@ -0,0 +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. diff --git a/internal/kibana/security_exception_item/resource.go b/internal/kibana/security_exception_item/resource.go new file mode 100644 index 000000000..c03b7f15e --- /dev/null +++ b/internal/kibana/security_exception_item/resource.go @@ -0,0 +1,41 @@ +package security_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{} + _ resource.ResourceWithValidateConfig = &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..26b37ecba --- /dev/null +++ b/internal/kibana/security_exception_item/schema.go @@ -0,0 +1,251 @@ +package security_exception_item + +import ( + "context" + _ "embed" + + "github.com/elastic/terraform-provider-elasticstack/internal/utils/validators" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/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(), + }, + }, + "space_id": schema.StringAttribute{ + MarkdownDescription: "An identifier for the space. If space_id is not provided, the default space is used.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "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.SetAttribute{ + MarkdownDescription: "Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`.", + Optional: true, + ElementType: types.StringType, + }, + "tags": schema.SetAttribute{ + 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, + CustomType: jsontypes.NormalizedType{}, + }, + "entries": schema.ListNestedAttribute{ + MarkdownDescription: "The exception item entries. This defines the conditions under which the exception applies.", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "The type of entry. Valid values: `match`, `match_any`, `list`, `exists`, `nested`, `wildcard`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("match", "match_any", "list", "exists", "nested", "wildcard"), + }, + }, + "field": schema.StringAttribute{ + MarkdownDescription: "The field name. Required for all entry types.", + Required: true, + }, + "operator": schema.StringAttribute{ + MarkdownDescription: "The operator to use. Valid values: `included`, `excluded`. Note: The operator field is not supported for nested entry types and will be ignored if specified.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("included", "excluded"), + }, + }, + "value": schema.StringAttribute{ + MarkdownDescription: "The value to match (for `match` and `wildcard` types).", + Optional: true, + Validators: []validator.String{ + validators.RequiredIfDependentPathOneOf( + path.Root("type"), + []string{"match", "wildcard"}, + ), + }, + }, + "values": schema.ListAttribute{ + ElementType: types.StringType, + MarkdownDescription: "Array of values to match (for `match_any` type).", + Optional: true, + }, + "list": schema.SingleNestedAttribute{ + MarkdownDescription: "Value list reference (for `list` type).", + Optional: true, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The value list ID.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The value list type (e.g., `keyword`, `ip`, `ip_range`).", + Required: true, + }, + }, + }, + "entries": schema.ListNestedAttribute{ + MarkdownDescription: "Nested entries (for `nested` type). Only `match`, `match_any`, and `exists` entry types are allowed as nested entries.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "The type of nested entry. Valid values: `match`, `match_any`, `exists`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("match", "match_any", "exists"), + }, + }, + "field": schema.StringAttribute{ + MarkdownDescription: "The field name.", + Required: true, + }, + "operator": schema.StringAttribute{ + MarkdownDescription: "The operator to use. Valid values: `included`, `excluded`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("included", "excluded"), + }, + }, + "value": schema.StringAttribute{ + MarkdownDescription: "The value to match (for `match` type).", + Optional: true, + Validators: []validator.String{ + validators.RequiredIfDependentPathOneOf( + path.Root("type"), + []string{"match"}, + ), + }, + }, + "values": schema.ListAttribute{ + ElementType: types.StringType, + MarkdownDescription: "Array of values to match (for `match_any` type).", + Optional: true, + Validators: []validator.List{ + validators.RequiredIfDependentPathOneOf( + path.Root("type"), + []string{"match_any"}, + ), + }, + }, + }, + }, + }, + }, + }, + }, + "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 RFC3339 format. This field is only available for regular exception items, not endpoint exceptions.", + Optional: true, + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "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/testdata/TestAccResourceExceptionItem/create/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf new file mode 100644 index 000000000..383f279ae --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf @@ -0,0 +1,65 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + 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 = var.item_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/update/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/update/exception_item.tf new file mode 100644 index 000000000..c3423ad43 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/update/exception_item.tf @@ -0,0 +1,65 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + 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 = var.item_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process-updated" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Exists/exists/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Exists/exists/exception_item.tf new file mode 100644 index 000000000..566d0809f --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Exists/exists/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Exists Entry" + description = "Test exception list for exists entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Exists Entry" + description = "Test exception item with exists entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "exists" + field = "file.hash.sha256" + operator = "included" + } + ] + tags = ["test", "exists"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_List/list/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_List/list/exception_item.tf new file mode 100644 index 000000000..caed9f9d5 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_List/list/exception_item.tf @@ -0,0 +1,64 @@ +variable "exception_list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +variable "value_list_id" { + description = "The value list ID" + type = string +} +variable "value_list_value" { + description = "The value list value" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.exception_list_id + name = "Test Exception List for List Entry" + description = "Test exception list for list entry type" + type = "detection" + namespace_type = "single" +} +resource "elasticstack_kibana_security_list_item" "test-item" { + list_id = elasticstack_kibana_security_list.test.list_id + value = var.value_list_value +} + +# Create a value list to reference in the exception item +resource "elasticstack_kibana_security_list" "test" { + list_id = var.value_list_id + name = "Test Value List" + description = "Test value list for list entry type" + type = "ip" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - List Entry" + description = "Test exception item with list entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "list" + field = "source.ip" + operator = "included" + list = { + id = elasticstack_kibana_security_list.test.list_id + type = "ip" + } + } + ] + tags = ["test", "list"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Match/match/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Match/match/exception_item.tf new file mode 100644 index 000000000..72106432b --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Match/match/exception_item.tf @@ -0,0 +1,40 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Match Entry" + description = "Test exception list for match entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Match Entry" + description = "Test exception item with match entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + } + ] + tags = ["test", "match"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_MatchAny/match_any/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_MatchAny/match_any/exception_item.tf new file mode 100644 index 000000000..7564996cb --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_MatchAny/match_any/exception_item.tf @@ -0,0 +1,40 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Match Any Entry" + description = "Test exception list for match_any entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Match Any Entry" + description = "Test exception item with match_any entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match_any" + field = "process.name" + operator = "included" + values = ["process1", "process2", "process3"] + } + ] + tags = ["test", "match_any"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested/exception_item.tf new file mode 100644 index 000000000..e1ad82dd8 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested/exception_item.tf @@ -0,0 +1,46 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Nested Entry" + description = "Test exception list for nested entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Entry" + description = "Test exception item with nested entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "match" + field = "nested.field" + operator = "included" + value = "nested-value" + } + ] + } + ] + tags = ["test", "nested"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_exists/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_exists/exception_item.tf new file mode 100644 index 000000000..73f94a82a --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_exists/exception_item.tf @@ -0,0 +1,45 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Nested Exists Entry" + description = "Test exception list for nested exists entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Exists Entry" + description = "Test exception item with nested exists entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "exists" + field = "nested.field" + operator = "included" + } + ] + } + ] + tags = ["test", "nested", "exists"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_match_any/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_match_any/exception_item.tf new file mode 100644 index 000000000..e4ff49ac3 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_match_any/exception_item.tf @@ -0,0 +1,46 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Nested Match Any Entry" + description = "Test exception list for nested match_any entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Match Any Entry" + description = "Test exception item with nested match_any entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "match_any" + field = "nested.field" + operator = "included" + values = ["value1", "value2", "value3"] + } + ] + } + ] + tags = ["test", "nested", "match_any"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Wildcard/wildcard/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Wildcard/wildcard/exception_item.tf new file mode 100644 index 000000000..ea49b6e3d --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Wildcard/wildcard/exception_item.tf @@ -0,0 +1,40 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Wildcard Entry" + description = "Test exception list for wildcard entry type" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Wildcard Entry" + description = "Test exception item with wildcard entry type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "wildcard" + field = "file.path" + operator = "included" + value = "/tmp/*.tmp" + } + ] + tags = ["test", "wildcard"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_create/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_create/exception_item.tf new file mode 100644 index 000000000..38498a942 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_create/exception_item.tf @@ -0,0 +1,40 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List - Agnostic" + description = "Test exception list with agnostic namespace type" + type = "detection" + namespace_type = "agnostic" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Agnostic" + description = "Test exception item with agnostic namespace type" + type = "simple" + namespace_type = "agnostic" + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + } + ] + tags = ["test", "agnostic"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_update/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_update/exception_item.tf new file mode 100644 index 000000000..890126b5d --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_update/exception_item.tf @@ -0,0 +1,40 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List - Agnostic" + description = "Test exception list with agnostic namespace type" + type = "detection" + namespace_type = "agnostic" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Agnostic Updated" + description = "Updated agnostic exception item" + type = "simple" + namespace_type = "agnostic" + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "updated-process" + } + ] + tags = ["test", "updated"] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_exists_missing_operator/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_exists_missing_operator/exception_item.tf new file mode 100644 index 000000000..7567268fc --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_exists_missing_operator/exception_item.tf @@ -0,0 +1,38 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Exists Missing Operator" + description = "Test validation: exists entry without operator" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "exists" + field = "file.hash.sha256" + # Missing operator - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_id/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_id/exception_item.tf new file mode 100644 index 000000000..f7e0e0f0d --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_id/exception_item.tf @@ -0,0 +1,42 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - List Missing List ID" + description = "Test validation: list entry without list.id" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "list" + field = "source.ip" + operator = "included" + list = { + type = "ip" + # Missing id - should trigger validation error + } + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_object/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_object/exception_item.tf new file mode 100644 index 000000000..009f9397d --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_object/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - List Missing List Object" + description = "Test validation: list entry without list object" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "list" + field = "source.ip" + operator = "included" + # Missing list object - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_type/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_type/exception_item.tf new file mode 100644 index 000000000..4e5b32275 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_type/exception_item.tf @@ -0,0 +1,42 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - List Missing List Type" + description = "Test validation: list entry without list.type" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "list" + field = "source.ip" + operator = "included" + list = { + id = "test-value-list" + # Missing type - should trigger validation error + } + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_operator/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_operator/exception_item.tf new file mode 100644 index 000000000..268dc730f --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_operator/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - MatchAny Missing Operator" + description = "Test validation: match_any entry without operator" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match_any" + field = "process.name" + values = ["process1", "process2"] + # Missing operator - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_values/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_values/exception_item.tf new file mode 100644 index 000000000..1b9618eb4 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_values/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - MatchAny Missing Values" + description = "Test validation: match_any entry without values" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match_any" + field = "process.name" + operator = "included" + # Missing values - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_operator/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_operator/exception_item.tf new file mode 100644 index 000000000..f7ffd3fde --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_operator/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Match Missing Operator" + description = "Test validation: match entry without operator" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match" + field = "process.name" + value = "test-process" + # Missing operator - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_value/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_value/exception_item.tf new file mode 100644 index 000000000..e21b8daae --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_value/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Match Missing Value" + description = "Test validation: match entry without value" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + # Missing value - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_operator/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_operator/exception_item.tf new file mode 100644 index 000000000..0408f440c --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_operator/exception_item.tf @@ -0,0 +1,45 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Entry Missing Operator" + description = "Test validation: nested match entry without operator" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "match" + field = "nested.field" + value = "test-value" + # Missing operator - should trigger validation error + } + ] + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_value/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_value/exception_item.tf new file mode 100644 index 000000000..0a293ec6c --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_value/exception_item.tf @@ -0,0 +1,45 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Entry Missing Value" + description = "Test validation: nested match entry without value" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "match" + field = "nested.field" + operator = "included" + # Missing value - should trigger validation error + } + ] + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_invalid_entry_type/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_invalid_entry_type/exception_item.tf new file mode 100644 index 000000000..bcbbfaa94 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_invalid_entry_type/exception_item.tf @@ -0,0 +1,45 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Invalid Entry Type" + description = "Test validation: nested entry with invalid nested entry type (wildcard)" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + entries = [ + { + type = "wildcard" + field = "nested.field" + operator = "included" + value = "test*" + } + ] + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_missing_entries/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_missing_entries/exception_item.tf new file mode 100644 index 000000000..ce02051eb --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_missing_entries/exception_item.tf @@ -0,0 +1,38 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Nested Missing Entries" + description = "Test validation: nested entry without entries" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "nested" + field = "parent.field" + # Missing entries - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_wildcard_missing_value/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_wildcard_missing_value/exception_item.tf new file mode 100644 index 000000000..de6fc230d --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_wildcard_missing_value/exception_item.tf @@ -0,0 +1,39 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List" + description = "Test exception list for validation" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Exception Item - Wildcard Missing Value" + description = "Test validation: wildcard entry without value" + type = "simple" + namespace_type = "single" + entries = [ + { + type = "wildcard" + field = "file.path" + operator = "included" + # Missing value - should trigger validation error + } + ] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/create/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/create/exception_item.tf new file mode 100644 index 000000000..c5a4f0a32 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/create/exception_item.tf @@ -0,0 +1,73 @@ +variable "space_id" { + description = "The Kibana space ID" + type = string +} + +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +resource "elasticstack_kibana_space" "test" { + space_id = var.space_id + name = "Test Space for Exception Items" + description = "Space for testing exception items" +} + +resource "elasticstack_kibana_security_exception_list" "test" { + space_id = elasticstack_kibana_space.test.space_id + list_id = var.list_id + name = "Test Exception List for Item" + description = "Test exception list" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + space_id = elasticstack_kibana_space.test.space_id + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process-space" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/update/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/update/exception_item.tf new file mode 100644 index 000000000..e27275925 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/update/exception_item.tf @@ -0,0 +1,73 @@ +variable "space_id" { + description = "The Kibana space ID" + type = string +} + +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "item_id" { + description = "The exception item ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +resource "elasticstack_kibana_space" "test" { + space_id = var.space_id + name = "Test Space for Exception Items" + description = "Space for testing exception items" +} + +resource "elasticstack_kibana_security_exception_list" "test" { + space_id = elasticstack_kibana_space.test.space_id + list_id = var.list_id + name = "Test Exception List for Item" + description = "Test exception list" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + space_id = elasticstack_kibana_space.test.space_id + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process-space-updated" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_create/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_create/exception_item.tf new file mode 100644 index 000000000..812201f3a --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_create/exception_item.tf @@ -0,0 +1,59 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Basic Item" + description = "Test exception list for basic usage" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_update/exception_item.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_update/exception_item.tf new file mode 100644 index 000000000..408389302 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_update/exception_item.tf @@ -0,0 +1,59 @@ +variable "list_id" { + description = "The exception list ID" + type = string +} + +variable "name" { + description = "The exception item name" + type = string +} + +variable "description" { + description = "The exception item description" + type = string +} + +variable "type" { + description = "The exception item type" + type = string +} + +variable "namespace_type" { + description = "The namespace type" + type = string +} + +variable "tags" { + description = "Tags for the exception item" + type = list(string) +} + +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_kibana_security_exception_list" "test" { + list_id = var.list_id + name = "Test Exception List for Basic Item" + description = "Test exception list for basic usage" + type = "detection" + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + name = var.name + description = var.description + type = var.type + namespace_type = var.namespace_type + entries = [ + { + type = "match" + field = "process.name" + operator = "included" + value = "test-process-updated" + } + ] + tags = var.tags +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_create/main.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_create/main.tf new file mode 100644 index 000000000..173b01320 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_create/main.tf @@ -0,0 +1,33 @@ +variable "list_id" { + type = string +} + +variable "item_id" { + type = string +} + +resource "elasticstack_kibana_security_exception_list" "test" { + name = "test exception list for complex item" + description = "test exception list for complex item" + type = "detection" + list_id = var.list_id + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Complex Exception Item" + description = "Test complex exception item for acceptance tests" + type = "simple" + namespace_type = "single" + os_types = ["linux", "macos"] + tags = ["test", "complex"] + + entries = [{ + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + }] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update/main.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update/main.tf new file mode 100644 index 000000000..0afc9a14e --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update/main.tf @@ -0,0 +1,33 @@ +variable "list_id" { + type = string +} + +variable "item_id" { + type = string +} + +resource "elasticstack_kibana_security_exception_list" "test" { + name = "test exception list for complex item" + description = "test exception list for complex item" + type = "detection" + list_id = var.list_id + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Complex Exception Item" + description = "Test complex exception item for acceptance tests" + type = "simple" + namespace_type = "single" + os_types = ["linux", "macos", "windows"] + tags = ["test", "complex", "updated"] + + entries = [{ + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + }] +} diff --git a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update_expire_time/main.tf b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update_expire_time/main.tf new file mode 100644 index 000000000..fd01ddc92 --- /dev/null +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update_expire_time/main.tf @@ -0,0 +1,38 @@ +variable "list_id" { + type = string +} + +variable "item_id" { + type = string +} + +variable "expire_time" { + type = string +} + +resource "elasticstack_kibana_security_exception_list" "test" { + name = "test exception list for complex item" + description = "test exception list for complex item" + type = "detection" + list_id = var.list_id + namespace_type = "single" +} + +resource "elasticstack_kibana_security_exception_item" "test" { + list_id = elasticstack_kibana_security_exception_list.test.list_id + item_id = var.item_id + name = "Test Complex Exception Item" + description = "Test complex exception item for acceptance tests" + type = "simple" + namespace_type = "single" + os_types = ["linux", "macos", "windows"] + tags = ["test", "complex", "updated"] + expire_time = var.expire_time + + entries = [{ + type = "match" + field = "process.name" + operator = "included" + value = "test-process" + }] +} diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go new file mode 100644 index 000000000..c762dbabe --- /dev/null +++ b/internal/kibana/security_exception_item/update.go @@ -0,0 +1,87 @@ +package security_exception_item + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "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 + } + + // Parse composite ID to get space_id and resource_id + compId, compIdDiags := clients.CompositeIdFromStrFw(plan.ID.ValueString()) + resp.Diagnostics.Append(compIdDiags...) + 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 using model method + body, diags := plan.toUpdateRequest(ctx, compId.ResourceId, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Update the exception item + updateResp, diags := kibana_oapi.UpdateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), *body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil { + resp.Diagnostics.AddError("Failed to update exception item", "API returned empty response") + return + } + + // In create/update paths we typically follow the write operation with a read, and then set the state from the read. + // We want to avoid a dirty plan immediately after an apply. + // Read back the updated resource to get the final state + readParams := &kbapi.ReadExceptionListItemParams{ + Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&updateResp.Id), + } + + // Include namespace_type if specified (required for agnostic items) + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + readParams.NamespaceType = &nsType + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response using model method + diags = plan.fromAPI(ctx, readResp) + 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_item/validate.go b/internal/kibana/security_exception_item/validate.go new file mode 100644 index 000000000..77d060e6a --- /dev/null +++ b/internal/kibana/security_exception_item/validate.go @@ -0,0 +1,226 @@ +package security_exception_item + +import ( + "context" + "fmt" + + "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/basetypes" +) + +// ValidateConfig validates the configuration for an exception item resource. +// It ensures that entries are properly configured based on their type: +// +// - For "match" and "wildcard" types: 'value' must be set +// - For "match_any" type: 'values' must be set +// - For "list" type: 'list' object must be set with 'id' and 'type' +// - For "exists" type: only 'field' and 'operator' are required +// - For "nested" type: 'entries' must be set and validated recursively +// - The 'operator' field is required for all types except "nested" +// +// Validation only runs on known values. Values that are unknown (e.g., references to +// other resources that haven't been created yet) are skipped. +// +// The function adds appropriate error diagnostics if validation fails. +func (r *ExceptionItemResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data ExceptionItemModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Validate entries + if !utils.IsKnown(data.Entries) { + return + } + + var entries []EntryModel + resp.Diagnostics.Append(data.Entries.ElementsAs(ctx, &entries, false)...) + if resp.Diagnostics.HasError() { + return + } + + for i, entry := range entries { + validateEntry(ctx, entry, i, &resp.Diagnostics, "entries") + } +} + +// validateEntry validates a single entry based on its type +func validateEntry(ctx context.Context, entry EntryModel, index int, diags *diag.Diagnostics, path string) { + if !utils.IsKnown(entry.Type) { + return + } + + entryType := entry.Type.ValueString() + entryPath := fmt.Sprintf("%s[%d]", path, index) + + switch entryType { + case "match", "wildcard": + // 'value' is required (only validate if not unknown) + if entry.Value.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type '%s' requires 'value' to be set at %s.", entryType, entryPath), + ) + } + // 'operator' is required (only validate if not unknown) + if entry.Operator.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type '%s' requires 'operator' to be set at %s.", entryType, entryPath), + ) + } + + case "match_any": + // 'values' is required (only validate if not unknown) + if entry.Values.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'match_any' requires 'values' to be set at %s.", entryPath), + ) + } + // 'operator' is required (only validate if not unknown) + if entry.Operator.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'match_any' requires 'operator' to be set at %s.", entryPath), + ) + } + + case "list": + // 'list' object is required (only validate if not unknown) + if entry.List.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'list' requires 'list' object to be set at %s.", entryPath), + ) + } else if !entry.List.IsUnknown() { + // Only validate list contents if the list object itself is known + var listModel EntryListModel + d := entry.List.As(ctx, &listModel, basetypes.ObjectAsOptions{}) + if d.HasError() { + diags.Append(d...) + } else { + // Only validate if the values are not unknown + if listModel.ID.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'list' requires 'list.id' to be set at %s.", entryPath), + ) + } + + if listModel.Type.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'list' requires 'list.type' to be set at %s.", entryPath), + ) + } + } + } + // 'operator' is required (only validate if not unknown) + if entry.Operator.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'list' requires 'operator' to be set at %s.", entryPath), + ) + } + + case "exists": + // Only 'field' and 'operator' are required (already handled by schema) + // 'operator' is required (only validate if not unknown) + if entry.Operator.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'exists' requires 'operator' to be set at %s.", entryPath), + ) + } + + case "nested": + // 'entries' is required for nested type (only validate if not unknown) + if entry.Entries.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Entry type 'nested' requires 'entries' to be set at %s.", entryPath), + ) + return + } + + // Skip validation if entries are unknown + if entry.Entries.IsUnknown() { + return + } + + // 'operator' should NOT be set for nested type + if utils.IsKnown(entry.Operator) { + diags.AddWarning( + "Ignored Field", + fmt.Sprintf("Entry type 'nested' does not support 'operator'. This field will be ignored at %s.", entryPath), + ) + } + + // Validate nested entries + var nestedEntries []NestedEntryModel + d := entry.Entries.ElementsAs(ctx, &nestedEntries, false) + if d.HasError() { + diags.Append(d...) + return + } + + for j, nestedEntry := range nestedEntries { + validateNestedEntry(ctx, nestedEntry, j, diags, fmt.Sprintf("%s.entries", entryPath)) + } + } +} + +// validateNestedEntry validates a nested entry within a "nested" type entry +func validateNestedEntry(ctx context.Context, entry NestedEntryModel, index int, diags *diag.Diagnostics, path string) { + if !utils.IsKnown(entry.Type) { + return + } + + entryType := entry.Type.ValueString() + entryPath := fmt.Sprintf("%s[%d]", path, index) + + // Nested entries can only be: match, match_any, or exists + switch entryType { + case "match": + // 'value' is required (only validate if not unknown) + if entry.Value.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Nested entry type 'match' requires 'value' to be set at %s.", entryPath), + ) + } + + case "match_any": + // 'values' is required (only validate if not unknown) + if entry.Values.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Nested entry type 'match_any' requires 'values' to be set at %s.", entryPath), + ) + } + + case "exists": + // Only 'field' and 'operator' are required (already handled by schema) + // Nothing additional to validate + + default: + diags.AddError( + "Invalid Entry Type", + fmt.Sprintf("Nested entry at %s has invalid type '%s'. Only 'match', 'match_any', and 'exists' are allowed for nested entries.", entryPath, entryType), + ) + } + + // 'operator' is always required for nested entries (only validate if not unknown) + if entry.Operator.IsNull() { + diags.AddError( + "Missing Required Field", + fmt.Sprintf("Nested entry requires 'operator' to be set at %s.", entryPath), + ) + } +} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 5519e80c5..9423ec18d 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -34,6 +34,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/maintenance_window" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_detection_rule" + "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_exception_item" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_exception_list" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_list" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/security_list_item" @@ -156,6 +157,7 @@ func (p *Provider) experimentalResources(ctx context.Context) []func() resource. security_list_item.NewResource, security_list.NewResource, security_exception_list.NewResource, + security_exception_item.NewResource, } }