From 4fc1308910ea987e16691773ab65293c935f51b6 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Thu, 27 Nov 2025 10:30:07 -0700 Subject: [PATCH 01/41] Add security exception item resource --- .../security_exception_item/acc_test.go | 474 +++++++++++++++ .../kibana/security_exception_item/create.go | 263 +++++++++ .../kibana/security_exception_item/delete.go | 34 ++ .../kibana/security_exception_item/models.go | 539 ++++++++++++++++++ .../kibana/security_exception_item/read.go | 52 ++ .../resource-description.md | 3 + .../security_exception_item/resource.go | 41 ++ .../kibana/security_exception_item/schema.go | 246 ++++++++ .../create/exception_item.tf | 65 +++ .../update/exception_item.tf | 65 +++ .../exists/exception_item.tf | 39 ++ .../list/exception_item.tf | 64 +++ .../match/exception_item.tf | 40 ++ .../match_any/exception_item.tf | 40 ++ .../nested/exception_item.tf | 46 ++ .../wildcard/exception_item.tf | 40 ++ .../exception_item.tf | 38 ++ .../exception_item.tf | 42 ++ .../exception_item.tf | 39 ++ .../exception_item.tf | 42 ++ .../exception_item.tf | 39 ++ .../exception_item.tf | 39 ++ .../exception_item.tf | 39 ++ .../exception_item.tf | 39 ++ .../exception_item.tf | 45 ++ .../exception_item.tf | 45 ++ .../exception_item.tf | 45 ++ .../exception_item.tf | 38 ++ .../exception_item.tf | 39 ++ .../create/exception_item.tf | 73 +++ .../update/exception_item.tf | 73 +++ .../kibana/security_exception_item/update.go | 164 ++++++ .../security_exception_item/validate.go | 226 ++++++++ 33 files changed, 3116 insertions(+) create mode 100644 internal/kibana/security_exception_item/acc_test.go create mode 100644 internal/kibana/security_exception_item/create.go create mode 100644 internal/kibana/security_exception_item/delete.go create mode 100644 internal/kibana/security_exception_item/models.go create mode 100644 internal/kibana/security_exception_item/read.go create mode 100644 internal/kibana/security_exception_item/resource-description.md create mode 100644 internal/kibana/security_exception_item/resource.go create mode 100644 internal/kibana/security_exception_item/schema.go create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/update/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Exists/exists/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_List/list/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Match/match/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_MatchAny/match_any/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Wildcard/wildcard/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_exists_missing_operator/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_id/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_object/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_list_missing_list_type/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_operator/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_any_missing_values/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_operator/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_match_missing_value/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_operator/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_entry_missing_value/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_invalid_entry_type/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_nested_missing_entries/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemValidation/validation_wildcard_missing_value/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/create/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemWithSpace/update/exception_item.tf create mode 100644 internal/kibana/security_exception_item/update.go create mode 100644 internal/kibana/security_exception_item/validate.go 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..d5ad643ac --- /dev/null +++ b/internal/kibana/security_exception_item/acc_test.go @@ -0,0 +1,474 @@ +package security_exception_item_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "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" +) + +var minExceptionItemAPISupport = version.Must(version.NewVersion("7.9.0")) + +func TestAccResourceExceptionItem(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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"), + ), + }, + }, + }) +} + +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, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.TestCheckResourceAttr(resourceName, "tags.0", "test"), + resource.TestCheckResourceAttr(resourceName, "tags.1", "space"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "entries.#"), + resource.TestCheckResourceAttrSet(resourceName, "created_at"), + resource.TestCheckResourceAttrSet(resourceName, "created_by"), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.TestCheckResourceAttr(resourceName, "tags.0", "test"), + resource.TestCheckResourceAttr(resourceName, "tags.1", "space"), + resource.TestCheckResourceAttr(resourceName, "tags.2", "updated"), + ), + }, + }, + }) +} + +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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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"), + ), + }, + }, + }) +} + +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) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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) }, + Steps: []resource.TestStep{ + // Test 1: Match entry missing value + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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, + }, + }, + }) +} diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go new file mode 100644 index 000000000..d826f2732 --- /dev/null +++ b/internal/kibana/security_exception_item/create.go @@ -0,0 +1,263 @@ +package security_exception_item + +import ( + "context" + "encoding/json" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ExceptionItemModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Convert entries from Terraform model to API model + entries, diags := convertEntriesToAPI(ctx, plan.Entries) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Build the request body + body := kbapi.CreateExceptionListItemJSONRequestBody{ + ListId: kbapi.SecurityExceptionsAPIExceptionListHumanId(plan.ListID.ValueString()), + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), + Entries: entries, + } + + // Set optional item_id + if utils.IsKnown(plan.ItemID) && !plan.ItemID.IsNull() { + itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(plan.ItemID.ValueString()) + body.ItemId = &itemID + } + + // Set optional namespace_type + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { + var comments []CommentModel + diags := plan.Comments.ElementsAs(ctx, &comments, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + body.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) + return + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + body.ExpireTime = &expireTimeAPI + } + + // Create the exception item + createResp, diags := kibana_oapi.CreateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if createResp == nil || createResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to create exception item", "API returned empty response") + return + } + + /* + * 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.JSON200.Id), + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *ExceptionItemResource) updateStateFromAPIResponse(ctx context.Context, model *ExceptionItemModel, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { + var diags diag.Diagnostics + + model.ID = types.StringValue(string(apiResp.Id)) + model.ItemID = types.StringValue(string(apiResp.ItemId)) + model.ListID = types.StringValue(string(apiResp.ListId)) + model.Name = types.StringValue(string(apiResp.Name)) + model.Description = types.StringValue(string(apiResp.Description)) + model.Type = types.StringValue(string(apiResp.Type)) + model.NamespaceType = types.StringValue(string(apiResp.NamespaceType)) + model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format("2006-01-02T15:04:05.000Z")) + model.CreatedBy = types.StringValue(apiResp.CreatedBy) + model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) + model.UpdatedBy = types.StringValue(apiResp.UpdatedBy) + model.TieBreakerID = types.StringValue(apiResp.TieBreakerId) + + // Set optional expire_time + if apiResp.ExpireTime != nil { + model.ExpireTime = types.StringValue(time.Time(*apiResp.ExpireTime).Format(time.RFC3339)) + } else { + model.ExpireTime = types.StringNull() + } + + // Set optional os_types + if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { + osTypes := make([]string, len(*apiResp.OsTypes)) + for i, osType := range *apiResp.OsTypes { + osTypes[i] = string(osType) + } + list, d := types.ListValueFrom(ctx, types.StringType, osTypes) + diags.Append(d...) + model.OsTypes = list + } else { + model.OsTypes = types.ListNull(types.StringType) + } + + // Set optional tags + if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { + list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) + diags.Append(d...) + model.Tags = list + } else { + model.Tags = types.ListNull(types.StringType) + } + + // Set optional meta + if apiResp.Meta != nil { + metaJSON, err := json.Marshal(apiResp.Meta) + if err != nil { + diags.AddError("Failed to serialize meta", err.Error()) + return diags + } + model.Meta = types.StringValue(string(metaJSON)) + } else { + model.Meta = types.StringNull() + } + + // Set entries (convert from API model to Terraform model) + entriesList, d := convertEntriesFromAPI(ctx, apiResp.Entries) + diags.Append(d...) + model.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: types.StringValue(string(comment.Id)), + Comment: types.StringValue(string(comment.Comment)), + } + } + list, d := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }, comments) + diags.Append(d...) + model.Comments = list + } else { + model.Comments = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }) + } + + return diags +} diff --git a/internal/kibana/security_exception_item/delete.go b/internal/kibana/security_exception_item/delete.go new file mode 100644 index 000000000..3b834b756 --- /dev/null +++ b/internal/kibana/security_exception_item/delete.go @@ -0,0 +1,34 @@ +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/hashicorp/terraform-plugin-framework/resource" +) + +func (r *ExceptionItemResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ExceptionItemModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Delete by ID + id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + params := &kbapi.DeleteExceptionListItemParams{ + Id: &id, + } + + diags = kibana_oapi.DeleteExceptionListItem(ctx, client, 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..5e4ab2499 --- /dev/null +++ b/internal/kibana/security_exception_item/models.go @@ -0,0 +1,539 @@ +package security_exception_item + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +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.List `tfsdk:"os_types"` + Tags types.List `tfsdk:"tags"` + Meta types.String `tfsdk:"meta"` + Entries types.List `tfsdk:"entries"` + Comments types.List `tfsdk:"comments"` + ExpireTime types.String `tfsdk:"expire_time"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` + TieBreakerID types.String `tfsdk:"tie_breaker_id"` +} + +type CommentModel struct { + ID types.String `tfsdk:"id"` + Comment types.String `tfsdk:"comment"` +} + +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 entries.IsNull() || entries.IsUnknown() { + return nil, diags + } + + var entryModels []EntryModel + diags.Append(entries.ElementsAs(ctx, &entryModels, false)...) + 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 +} + +// 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": + // Validate required field + if entry.Value.IsNull() || entry.Value.IsUnknown() || 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()) + } + + case "match_any": + // Validate required field + if entry.Values.IsNull() || entry.Values.IsUnknown() { + diags.AddError("Invalid Configuration", "Attribute 'values' is required when type is 'match_any'") + return result, diags + } + + var values []string + diags.Append(entry.Values.ElementsAs(ctx, &values, false)...) + 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()) + } + + case "list": + // Validate required field + if entry.List.IsNull() || entry.List.IsUnknown() { + 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()) + } + + case "exists": + 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()) + } + + case "wildcard": + // Validate required field + if entry.Value.IsNull() || entry.Value.IsUnknown() || 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()) + } + + case "nested": + // Validate required field + if entry.Entries.IsNull() || entry.Entries.IsUnknown() { + diags.AddError("Invalid Configuration", "Attribute 'entries' is required when type is 'nested'") + return result, diags + } + + var nestedEntries []NestedEntryModel + diags.Append(entry.Entries.ElementsAs(ctx, &nestedEntries, false)...) + 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()) + } + + default: + diags.AddError("Invalid entry type", fmt.Sprintf("Unknown entry type: %s", entryType)) + } + + 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": + // Validate required field + if entry.Value.IsNull() || entry.Value.IsUnknown() || 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()) + } + + case "match_any": + // Validate required field + if entry.Values.IsNull() || entry.Values.IsUnknown() { + diags.AddError("Invalid Configuration", "Attribute 'values' is required for nested entry when type is 'match_any'") + return result, diags + } + + var values []string + diags.Append(entry.Values.ElementsAs(ctx, &values, false)...) + 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()) + } + + case "exists": + 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()) + } + + 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 +} + +// 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": + 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()}) + + case "match_any": + 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()}) + + case "list": + 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()}) + + case "exists": + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + entry.List = types.ObjectNull(getListAttrTypes()) + entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) + + case "nested": + // 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 entry, diags +} + +// 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": + if value, ok := entryMap["value"].(string); ok { + entry.Value = types.StringValue(value) + } else { + entry.Value = types.StringNull() + } + entry.Values = types.ListNull(types.StringType) + + case "match_any": + 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() + + case "exists": + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + } + + 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}, + } +} diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go new file mode 100644 index 000000000..2f2b24880 --- /dev/null +++ b/internal/kibana/security_exception_item/read.go @@ -0,0 +1,52 @@ +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/hashicorp/terraform-plugin-framework/resource" +) + +func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ExceptionItemModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Read by ID + id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + params := &kbapi.ReadExceptionListItemParams{ + Id: &id, + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, state.SpaceID.ValueString(), params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with response + diags = r.updateStateFromAPIResponse(ctx, &state, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/kibana/security_exception_item/resource-description.md b/internal/kibana/security_exception_item/resource-description.md new file mode 100644 index 000000000..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..86d5d81e0 --- /dev/null +++ b/internal/kibana/security_exception_item/schema.go @@ -0,0 +1,246 @@ +package security_exception_item + +import ( + "context" + _ "embed" + + "github.com/elastic/terraform-provider-elasticstack/internal/utils/validators" + "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.ListAttribute{ + MarkdownDescription: "Array of OS types for which the exceptions apply. Valid values: `linux`, `macos`, `windows`.", + Optional: true, + ElementType: types.StringType, + }, + "tags": schema.ListAttribute{ + MarkdownDescription: "String array containing words and phrases to help categorize exception items.", + Optional: true, + ElementType: types.StringType, + }, + "meta": schema.StringAttribute{ + MarkdownDescription: "Placeholder for metadata about the exception item as JSON string.", + Optional: true, + }, + "entries": schema.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, + }, + "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_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/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/update.go b/internal/kibana/security_exception_item/update.go new file mode 100644 index 000000000..abebae6c2 --- /dev/null +++ b/internal/kibana/security_exception_item/update.go @@ -0,0 +1,164 @@ +package security_exception_item + +import ( + "context" + "encoding/json" + "time" + + "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ExceptionItemModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetKibanaOapiClient() + if err != nil { + resp.Diagnostics.AddError("Failed to get Kibana client", err.Error()) + return + } + + // Convert entries from Terraform model to API model + entries, diags := convertEntriesToAPI(ctx, plan.Entries) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Build the update request body + id := kbapi.SecurityExceptionsAPIExceptionListItemId(plan.ID.ValueString()) + body := kbapi.UpdateExceptionListItemJSONRequestBody{ + Id: &id, + Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), + Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), + Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), + Entries: entries, + } + + // Set optional namespace_type + if utils.IsKnown(plan.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) + body.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { + var osTypes []string + diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + body.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { + var tags []string + diags := plan.Tags.ElementsAs(ctx, &tags, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + body.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { + resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) + return + } + body.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { + var comments []CommentModel + diags := plan.Comments.ElementsAs(ctx, &comments, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + body.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) + return + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + body.ExpireTime = &expireTimeAPI + } + + // Update the exception item + updateResp, diags := kibana_oapi.UpdateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if updateResp == nil || updateResp.JSON200 == nil { + resp.Diagnostics.AddError("Failed to update exception item", "API returned empty response") + return + } + + /* + * 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.JSON200.Id), + } + + readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if readResp == nil || readResp.JSON200 == nil { + resp.State.RemoveResource(ctx) + return + } + + // Update state with read response + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} 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), + ) + } +} From 807c6a3a252972e4c0b5e7fce595812eb8527b41 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Sun, 30 Nov 2025 21:33:20 -0700 Subject: [PATCH 02/41] Add resource to provider --- provider/plugin_framework.go | 2 ++ 1 file changed, 2 insertions(+) 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, } } From 0859414d166b8020ceafd9ad238d30e25f8ffa1a Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Sun, 30 Nov 2025 21:33:37 -0700 Subject: [PATCH 03/41] Refactor to handle new exception client helper api --- internal/kibana/security_exception_item/create.go | 8 ++++---- internal/kibana/security_exception_item/read.go | 4 ++-- internal/kibana/security_exception_item/update.go | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go index d826f2732..3f0c61482 100644 --- a/internal/kibana/security_exception_item/create.go +++ b/internal/kibana/security_exception_item/create.go @@ -135,7 +135,7 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR return } - if createResp == nil || createResp.JSON200 == nil { + if createResp == nil { resp.Diagnostics.AddError("Failed to create exception item", "API returned empty response") return } @@ -146,7 +146,7 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR */ // Read back the created resource to get the final state readParams := &kbapi.ReadExceptionListItemParams{ - Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&createResp.JSON200.Id), + Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&createResp.Id), } readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) @@ -155,13 +155,13 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR return } - if readResp == nil || readResp.JSON200 == nil { + if readResp == nil { resp.State.RemoveResource(ctx) return } // Update state with read response - diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go index 2f2b24880..41954fb44 100644 --- a/internal/kibana/security_exception_item/read.go +++ b/internal/kibana/security_exception_item/read.go @@ -35,13 +35,13 @@ func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadReque return } - if readResp == nil || readResp.JSON200 == nil { + if readResp == nil { resp.State.RemoveResource(ctx) return } // Update state with response - diags = r.updateStateFromAPIResponse(ctx, &state, readResp.JSON200) + diags = r.updateStateFromAPIResponse(ctx, &state, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go index abebae6c2..96407ea3a 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -127,7 +127,7 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR return } - if updateResp == nil || updateResp.JSON200 == nil { + if updateResp == nil { resp.Diagnostics.AddError("Failed to update exception item", "API returned empty response") return } @@ -138,7 +138,7 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR */ // Read back the updated resource to get the final state readParams := &kbapi.ReadExceptionListItemParams{ - Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&updateResp.JSON200.Id), + Id: (*kbapi.SecurityExceptionsAPIExceptionListItemId)(&updateResp.Id), } readResp, diags := kibana_oapi.GetExceptionListItem(ctx, client, plan.SpaceID.ValueString(), readParams) @@ -147,13 +147,13 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR return } - if readResp == nil || readResp.JSON200 == nil { + if readResp == nil { resp.State.RemoveResource(ctx) return } // Update state with read response - diags = r.updateStateFromAPIResponse(ctx, &plan, readResp.JSON200) + diags = r.updateStateFromAPIResponse(ctx, &plan, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From ae4fa04f156a9e4e5a514c6bb569d98419fafed5 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Sun, 30 Nov 2025 22:20:46 -0700 Subject: [PATCH 04/41] Extract create / update request building to helper in models.go --- .../kibana/security_exception_item/create.go | 199 +----------- .../kibana/security_exception_item/models.go | 299 ++++++++++++++++++ .../kibana/security_exception_item/read.go | 4 +- .../kibana/security_exception_item/update.go | 100 +----- 4 files changed, 311 insertions(+), 291 deletions(-) diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go index 3f0c61482..db964d82d 100644 --- a/internal/kibana/security_exception_item/create.go +++ b/internal/kibana/security_exception_item/create.go @@ -2,16 +2,10 @@ package security_exception_item import ( "context" - "encoding/json" - "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/types" ) func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { @@ -29,107 +23,15 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR return } - // Convert entries from Terraform model to API model - entries, diags := convertEntriesToAPI(ctx, plan.Entries) + // Build the request body using model method + body, diags := plan.toCreateRequest(ctx) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // Build the request body - body := kbapi.CreateExceptionListItemJSONRequestBody{ - ListId: kbapi.SecurityExceptionsAPIExceptionListHumanId(plan.ListID.ValueString()), - Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), - Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), - Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), - Entries: entries, - } - - // Set optional item_id - if utils.IsKnown(plan.ItemID) && !plan.ItemID.IsNull() { - itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(plan.ItemID.ValueString()) - body.ItemId = &itemID - } - - // Set optional namespace_type - if utils.IsKnown(plan.NamespaceType) { - nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) - body.NamespaceType = &nsType - } - - // Set optional os_types - if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { - var osTypes []string - diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(osTypes) > 0 { - osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) - for i, osType := range osTypes { - osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) - } - body.OsTypes = &osTypesArray - } - } - - // Set optional tags - if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { - var tags []string - diags := plan.Tags.ElementsAs(ctx, &tags, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(tags) > 0 { - tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) - body.Tags = &tagsArray - } - } - - // Set optional meta - if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { - var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta - if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { - resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) - return - } - body.Meta = &meta - } - - // Set optional comments - if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { - var comments []CommentModel - diags := plan.Comments.ElementsAs(ctx, &comments, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(comments) > 0 { - commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) - for i, comment := range comments { - commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ - Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), - } - } - body.Comments = &commentsArray - } - } - - // Set optional expire_time - if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { - expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) - return - } - expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) - body.ExpireTime = &expireTimeAPI - } - // Create the exception item - createResp, diags := kibana_oapi.CreateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), body) + createResp, diags := kibana_oapi.CreateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), *body) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -160,8 +62,8 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR return } - // Update state with read response - diags = r.updateStateFromAPIResponse(ctx, &plan, readResp) + // Update state with read response using model method + diags = plan.fromAPI(ctx, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -170,94 +72,3 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) } - -func (r *ExceptionItemResource) updateStateFromAPIResponse(ctx context.Context, model *ExceptionItemModel, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { - var diags diag.Diagnostics - - model.ID = types.StringValue(string(apiResp.Id)) - model.ItemID = types.StringValue(string(apiResp.ItemId)) - model.ListID = types.StringValue(string(apiResp.ListId)) - model.Name = types.StringValue(string(apiResp.Name)) - model.Description = types.StringValue(string(apiResp.Description)) - model.Type = types.StringValue(string(apiResp.Type)) - model.NamespaceType = types.StringValue(string(apiResp.NamespaceType)) - model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format("2006-01-02T15:04:05.000Z")) - model.CreatedBy = types.StringValue(apiResp.CreatedBy) - model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format("2006-01-02T15:04:05.000Z")) - model.UpdatedBy = types.StringValue(apiResp.UpdatedBy) - model.TieBreakerID = types.StringValue(apiResp.TieBreakerId) - - // Set optional expire_time - if apiResp.ExpireTime != nil { - model.ExpireTime = types.StringValue(time.Time(*apiResp.ExpireTime).Format(time.RFC3339)) - } else { - model.ExpireTime = types.StringNull() - } - - // Set optional os_types - if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { - osTypes := make([]string, len(*apiResp.OsTypes)) - for i, osType := range *apiResp.OsTypes { - osTypes[i] = string(osType) - } - list, d := types.ListValueFrom(ctx, types.StringType, osTypes) - diags.Append(d...) - model.OsTypes = list - } else { - model.OsTypes = types.ListNull(types.StringType) - } - - // Set optional tags - if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { - list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) - diags.Append(d...) - model.Tags = list - } else { - model.Tags = types.ListNull(types.StringType) - } - - // Set optional meta - if apiResp.Meta != nil { - metaJSON, err := json.Marshal(apiResp.Meta) - if err != nil { - diags.AddError("Failed to serialize meta", err.Error()) - return diags - } - model.Meta = types.StringValue(string(metaJSON)) - } else { - model.Meta = types.StringNull() - } - - // Set entries (convert from API model to Terraform model) - entriesList, d := convertEntriesFromAPI(ctx, apiResp.Entries) - diags.Append(d...) - model.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: types.StringValue(string(comment.Id)), - Comment: types.StringValue(string(comment.Comment)), - } - } - list, d := types.ListValueFrom(ctx, types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "comment": types.StringType, - }, - }, comments) - diags.Append(d...) - model.Comments = list - } else { - model.Comments = types.ListNull(types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "comment": types.StringType, - }, - }) - } - - return diags -} diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 5e4ab2499..8d9f62f83 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" @@ -537,3 +539,300 @@ func getNestedEntryAttrTypes() map[string]attr.Type { "values": types.ListType{ElemType: types.StringType}, } } + +// toCreateRequest converts the Terraform model to API create request +func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.CreateExceptionListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + // Convert entries from Terraform model to API model + entries, d := convertEntriesToAPI(ctx, m.Entries) + diags.Append(d...) + 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) && !m.ItemID.IsNull() { + itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(m.ItemID.ValueString()) + req.ItemId = &itemID + } + + // Set optional namespace_type + if utils.IsKnown(m.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(m.NamespaceType.ValueString()) + req.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(m.OsTypes) && !m.OsTypes.IsNull() { + var osTypes []string + d := m.OsTypes.ElementsAs(ctx, &osTypes, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + req.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(m.Tags) && !m.Tags.IsNull() { + var tags []string + d := m.Tags.ElementsAs(ctx, &tags, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + req.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(m.Meta) && !m.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { + diags.AddError("Failed to parse meta JSON", err.Error()) + return nil, diags + } + req.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { + var comments []CommentModel + d := m.Comments.ElementsAs(ctx, &comments, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + req.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, m.ExpireTime.ValueString()) + if err != nil { + diags.AddError("Failed to parse expire_time", err.Error()) + return nil, diags + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + req.ExpireTime = &expireTimeAPI + } + + return req, diags +} + +// toUpdateRequest converts the Terraform model to API update request +func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId string) (*kbapi.UpdateExceptionListItemJSONRequestBody, diag.Diagnostics) { + var diags diag.Diagnostics + + // Convert entries from Terraform model to API model + entries, d := convertEntriesToAPI(ctx, m.Entries) + diags.Append(d...) + 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 optional namespace_type + if utils.IsKnown(m.NamespaceType) { + nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(m.NamespaceType.ValueString()) + req.NamespaceType = &nsType + } + + // Set optional os_types + if utils.IsKnown(m.OsTypes) && !m.OsTypes.IsNull() { + var osTypes []string + d := m.OsTypes.ElementsAs(ctx, &osTypes, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(osTypes) > 0 { + osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) + for i, osType := range osTypes { + osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) + } + req.OsTypes = &osTypesArray + } + } + + // Set optional tags + if utils.IsKnown(m.Tags) && !m.Tags.IsNull() { + var tags []string + d := m.Tags.ElementsAs(ctx, &tags, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(tags) > 0 { + tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) + req.Tags = &tagsArray + } + } + + // Set optional meta + if utils.IsKnown(m.Meta) && !m.Meta.IsNull() { + var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta + if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { + diags.AddError("Failed to parse meta JSON", err.Error()) + return nil, diags + } + req.Meta = &meta + } + + // Set optional comments + if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { + var comments []CommentModel + d := m.Comments.ElementsAs(ctx, &comments, false) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + if len(comments) > 0 { + commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) + for i, comment := range comments { + commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ + Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), + } + } + req.Comments = &commentsArray + } + } + + // Set optional expire_time + if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { + expireTime, err := time.Parse(time.RFC3339, m.ExpireTime.ValueString()) + if err != nil { + diags.AddError("Failed to parse expire_time", err.Error()) + return nil, diags + } + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + req.ExpireTime = &expireTimeAPI + } + + 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 + + m.ID = types.StringValue(string(apiResp.Id)) + m.ItemID = types.StringValue(string(apiResp.ItemId)) + m.ListID = types.StringValue(string(apiResp.ListId)) + m.Name = types.StringValue(string(apiResp.Name)) + m.Description = types.StringValue(string(apiResp.Description)) + m.Type = types.StringValue(string(apiResp.Type)) + m.NamespaceType = types.StringValue(string(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 { + m.ExpireTime = types.StringValue(time.Time(*apiResp.ExpireTime).Format(time.RFC3339)) + } else { + m.ExpireTime = types.StringNull() + } + + // Set optional os_types + if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { + osTypes := make([]string, len(*apiResp.OsTypes)) + for i, osType := range *apiResp.OsTypes { + osTypes[i] = string(osType) + } + list, d := types.ListValueFrom(ctx, types.StringType, osTypes) + diags.Append(d...) + m.OsTypes = list + } else { + m.OsTypes = types.ListNull(types.StringType) + } + + // Set optional tags + if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { + list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) + diags.Append(d...) + m.Tags = list + } else { + m.Tags = types.ListNull(types.StringType) + } + + // Set optional meta + if apiResp.Meta != nil { + metaJSON, err := json.Marshal(apiResp.Meta) + if err != nil { + diags.AddError("Failed to serialize meta", err.Error()) + return diags + } + m.Meta = types.StringValue(string(metaJSON)) + } else { + m.Meta = types.StringNull() + } + + // 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: types.StringValue(string(comment.Id)), + Comment: types.StringValue(string(comment.Comment)), + } + } + list, d := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }, comments) + diags.Append(d...) + m.Comments = list + } else { + m.Comments = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "comment": types.StringType, + }, + }) + } + + return diags +} diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go index 41954fb44..a99798c5b 100644 --- a/internal/kibana/security_exception_item/read.go +++ b/internal/kibana/security_exception_item/read.go @@ -40,8 +40,8 @@ func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadReque return } - // Update state with response - diags = r.updateStateFromAPIResponse(ctx, &state, readResp) + // Update state with response using model method + diags = state.fromAPI(ctx, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go index 96407ea3a..0c63d8bca 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -2,12 +2,9 @@ package security_exception_item import ( "context" - "encoding/json" - "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/resource" ) @@ -26,102 +23,15 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR return } - // Convert entries from Terraform model to API model - entries, diags := convertEntriesToAPI(ctx, plan.Entries) + // Build the update request body using model method + body, diags := plan.toUpdateRequest(ctx, plan.ID.ValueString()) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // Build the update request body - id := kbapi.SecurityExceptionsAPIExceptionListItemId(plan.ID.ValueString()) - body := kbapi.UpdateExceptionListItemJSONRequestBody{ - Id: &id, - Name: kbapi.SecurityExceptionsAPIExceptionListItemName(plan.Name.ValueString()), - Description: kbapi.SecurityExceptionsAPIExceptionListItemDescription(plan.Description.ValueString()), - Type: kbapi.SecurityExceptionsAPIExceptionListItemType(plan.Type.ValueString()), - Entries: entries, - } - - // Set optional namespace_type - if utils.IsKnown(plan.NamespaceType) { - nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(plan.NamespaceType.ValueString()) - body.NamespaceType = &nsType - } - - // Set optional os_types - if utils.IsKnown(plan.OsTypes) && !plan.OsTypes.IsNull() { - var osTypes []string - diags := plan.OsTypes.ElementsAs(ctx, &osTypes, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(osTypes) > 0 { - osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) - for i, osType := range osTypes { - osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) - } - body.OsTypes = &osTypesArray - } - } - - // Set optional tags - if utils.IsKnown(plan.Tags) && !plan.Tags.IsNull() { - var tags []string - diags := plan.Tags.ElementsAs(ctx, &tags, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(tags) > 0 { - tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) - body.Tags = &tagsArray - } - } - - // Set optional meta - if utils.IsKnown(plan.Meta) && !plan.Meta.IsNull() { - var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta - if err := json.Unmarshal([]byte(plan.Meta.ValueString()), &meta); err != nil { - resp.Diagnostics.AddError("Failed to parse meta JSON", err.Error()) - return - } - body.Meta = &meta - } - - // Set optional comments - if utils.IsKnown(plan.Comments) && !plan.Comments.IsNull() { - var comments []CommentModel - diags := plan.Comments.ElementsAs(ctx, &comments, false) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - if len(comments) > 0 { - commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) - for i, comment := range comments { - commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ - Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), - } - } - body.Comments = &commentsArray - } - } - - // Set optional expire_time - if utils.IsKnown(plan.ExpireTime) && !plan.ExpireTime.IsNull() { - expireTime, err := time.Parse(time.RFC3339, plan.ExpireTime.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to parse expire_time", err.Error()) - return - } - expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) - body.ExpireTime = &expireTimeAPI - } - // Update the exception item - updateResp, diags := kibana_oapi.UpdateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), body) + updateResp, diags := kibana_oapi.UpdateExceptionListItem(ctx, client, plan.SpaceID.ValueString(), *body) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -152,8 +62,8 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR return } - // Update state with read response - diags = r.updateStateFromAPIResponse(ctx, &plan, readResp) + // Update state with read response using model method + diags = plan.fromAPI(ctx, readResp) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From cf369c67990c5e722158dd61d7769977cd98fc4e Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Sun, 30 Nov 2025 22:34:20 -0700 Subject: [PATCH 05/41] Refactor to adhere to coding standards --- .../kibana/security_exception_item/models.go | 137 ++++++++---------- .../kibana/security_exception_item/schema.go | 6 +- 2 files changed, 67 insertions(+), 76 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 8d9f62f83..38168b7b7 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -8,32 +8,35 @@ import ( "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/utils/typeutils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) 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.List `tfsdk:"os_types"` - Tags types.List `tfsdk:"tags"` - Meta types.String `tfsdk:"meta"` - Entries types.List `tfsdk:"entries"` - Comments types.List `tfsdk:"comments"` - ExpireTime types.String `tfsdk:"expire_time"` - CreatedAt types.String `tfsdk:"created_at"` - CreatedBy types.String `tfsdk:"created_by"` - UpdatedAt types.String `tfsdk:"updated_at"` - UpdatedBy types.String `tfsdk:"updated_by"` - TieBreakerID types.String `tfsdk:"tie_breaker_id"` + 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 types.String `tfsdk:"expire_time"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` + TieBreakerID types.String `tfsdk:"tie_breaker_id"` } type CommentModel struct { @@ -72,8 +75,7 @@ func convertEntriesToAPI(ctx context.Context, entries types.List) (kbapi.Securit return nil, diags } - var entryModels []EntryModel - diags.Append(entries.ElementsAs(ctx, &entryModels, false)...) + entryModels := utils.ListTypeAs[EntryModel](ctx, entries, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -125,8 +127,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc return result, diags } - var values []string - diags.Append(entry.Values.ElementsAs(ctx, &values, false)...) + values := utils.ListTypeAs[string](ctx, entry.Values, path.Empty(), &diags) if diags.HasError() { return result, diags } @@ -207,8 +208,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc return result, diags } - var nestedEntries []NestedEntryModel - diags.Append(entry.Entries.ElementsAs(ctx, &nestedEntries, false)...) + nestedEntries := utils.ListTypeAs[NestedEntryModel](ctx, entry.Entries, path.Empty(), &diags) if diags.HasError() { return result, diags } @@ -278,8 +278,7 @@ func convertNestedEntryToAPI(ctx context.Context, entry NestedEntryModel) (kbapi return result, diags } - var values []string - diags.Append(entry.Values.ElementsAs(ctx, &values, false)...) + values := utils.ListTypeAs[string](ctx, entry.Values, path.Empty(), &diags) if diags.HasError() { return result, diags } @@ -572,10 +571,8 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional os_types - if utils.IsKnown(m.OsTypes) && !m.OsTypes.IsNull() { - var osTypes []string - d := m.OsTypes.ElementsAs(ctx, &osTypes, false) - diags.Append(d...) + if utils.IsKnown(m.OsTypes) { + osTypes := utils.SetTypeAs[string](ctx, m.OsTypes, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -589,10 +586,8 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional tags - if utils.IsKnown(m.Tags) && !m.Tags.IsNull() { - var tags []string - d := m.Tags.ElementsAs(ctx, &tags, false) - diags.Append(d...) + if utils.IsKnown(m.Tags) { + tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -603,10 +598,11 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional meta - if utils.IsKnown(m.Meta) && !m.Meta.IsNull() { + if utils.IsKnown(m.Meta) { var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta - if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { - diags.AddError("Failed to parse meta JSON", err.Error()) + unmarshalDiags := m.Meta.Unmarshal(&meta) + diags.Append(unmarshalDiags...) + if diags.HasError() { return nil, diags } req.Meta = &meta @@ -614,9 +610,7 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create // Set optional comments if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { - var comments []CommentModel - d := m.Comments.ElementsAs(ctx, &comments, false) - diags.Append(d...) + comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -672,10 +666,8 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str } // Set optional os_types - if utils.IsKnown(m.OsTypes) && !m.OsTypes.IsNull() { - var osTypes []string - d := m.OsTypes.ElementsAs(ctx, &osTypes, false) - diags.Append(d...) + if utils.IsKnown(m.OsTypes) { + osTypes := utils.SetTypeAs[string](ctx, m.OsTypes, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -689,10 +681,8 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str } // Set optional tags - if utils.IsKnown(m.Tags) && !m.Tags.IsNull() { - var tags []string - d := m.Tags.ElementsAs(ctx, &tags, false) - diags.Append(d...) + if utils.IsKnown(m.Tags) { + tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -703,10 +693,11 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str } // Set optional meta - if utils.IsKnown(m.Meta) && !m.Meta.IsNull() { + if utils.IsKnown(m.Meta) { var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta - if err := json.Unmarshal([]byte(m.Meta.ValueString()), &meta); err != nil { - diags.AddError("Failed to parse meta JSON", err.Error()) + unmarshalDiags := m.Meta.Unmarshal(&meta) + diags.Append(unmarshalDiags...) + if diags.HasError() { return nil, diags } req.Meta = &meta @@ -714,9 +705,7 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str // Set optional comments if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { - var comments []CommentModel - d := m.Comments.ElementsAs(ctx, &comments, false) - diags.Append(d...) + comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) if diags.HasError() { return nil, diags } @@ -749,13 +738,13 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { var diags diag.Diagnostics - m.ID = types.StringValue(string(apiResp.Id)) - m.ItemID = types.StringValue(string(apiResp.ItemId)) - m.ListID = types.StringValue(string(apiResp.ListId)) - m.Name = types.StringValue(string(apiResp.Name)) - m.Description = types.StringValue(string(apiResp.Description)) - m.Type = types.StringValue(string(apiResp.Type)) - m.NamespaceType = types.StringValue(string(apiResp.NamespaceType)) + m.ID = typeutils.StringishValue(apiResp.Id) + 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")) @@ -775,32 +764,32 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit for i, osType := range *apiResp.OsTypes { osTypes[i] = string(osType) } - list, d := types.ListValueFrom(ctx, types.StringType, osTypes) + set, d := types.SetValueFrom(ctx, types.StringType, osTypes) diags.Append(d...) - m.OsTypes = list + m.OsTypes = set } else { - m.OsTypes = types.ListNull(types.StringType) + m.OsTypes = types.SetNull(types.StringType) } // Set optional tags if apiResp.Tags != nil && len(*apiResp.Tags) > 0 { - list, d := types.ListValueFrom(ctx, types.StringType, *apiResp.Tags) + set, d := types.SetValueFrom(ctx, types.StringType, *apiResp.Tags) diags.Append(d...) - m.Tags = list + m.Tags = set } else { - m.Tags = types.ListNull(types.StringType) + m.Tags = types.SetNull(types.StringType) } // Set optional meta if apiResp.Meta != nil { - metaJSON, err := json.Marshal(apiResp.Meta) + metaBytes, err := json.Marshal(apiResp.Meta) if err != nil { - diags.AddError("Failed to serialize meta", err.Error()) + diags.AddError("Failed to marshal meta field from API response to JSON", err.Error()) return diags } - m.Meta = types.StringValue(string(metaJSON)) + m.Meta = jsontypes.NewNormalizedValue(string(metaBytes)) } else { - m.Meta = types.StringNull() + m.Meta = jsontypes.NewNormalizedNull() } // Set entries (convert from API model to Terraform model) @@ -813,8 +802,8 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit comments := make([]CommentModel, len(apiResp.Comments)) for i, comment := range apiResp.Comments { comments[i] = CommentModel{ - ID: types.StringValue(string(comment.Id)), - Comment: types.StringValue(string(comment.Comment)), + ID: typeutils.StringishValue(comment.Id), + Comment: typeutils.StringishValue(comment.Comment), } } list, d := types.ListValueFrom(ctx, types.ObjectType{ diff --git a/internal/kibana/security_exception_item/schema.go b/internal/kibana/security_exception_item/schema.go index 86d5d81e0..4aa16b001 100644 --- a/internal/kibana/security_exception_item/schema.go +++ b/internal/kibana/security_exception_item/schema.go @@ -5,6 +5,7 @@ import ( _ "embed" "github.com/elastic/terraform-provider-elasticstack/internal/utils/validators" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -85,12 +86,12 @@ func (r *ExceptionItemResource) Schema(_ context.Context, _ resource.SchemaReque stringplanmodifier.RequiresReplace(), }, }, - "os_types": schema.ListAttribute{ + "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.ListAttribute{ + "tags": schema.SetAttribute{ MarkdownDescription: "String array containing words and phrases to help categorize exception items.", Optional: true, ElementType: types.StringType, @@ -98,6 +99,7 @@ func (r *ExceptionItemResource) Schema(_ context.Context, _ resource.SchemaReque "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.", From ae3bcd8926dbc6c73fe594dff1917ae27adaa6c3 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Sun, 30 Nov 2025 22:34:34 -0700 Subject: [PATCH 06/41] Update coding standards to include typeutils --- CODING_STANDARDS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index c863aa64f..021ecae0d 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 eg `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 From 7d3df78c5b6ca66deb3c9c124378afbe8ae83718 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 09:20:53 -0700 Subject: [PATCH 07/41] Add example --- .../resource.tf | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 examples/resources/elasticstack_kibana_security_exception_item/resource.tf 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"] +} + From 887793f397fc9fb4d0a8d8eecc4849d36c90024f Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 09:48:21 -0700 Subject: [PATCH 08/41] Fix tests for tags as set --- internal/kibana/security_exception_item/acc_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index d5ad643ac..45f691197 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -103,8 +103,8 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "description", "Test exception item in custom space"), resource.TestCheckResourceAttr(resourceName, "type", "simple"), resource.TestCheckResourceAttr(resourceName, "namespace_type", "single"), - resource.TestCheckResourceAttr(resourceName, "tags.0", "test"), - resource.TestCheckResourceAttr(resourceName, "tags.1", "space"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "test"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "space"), resource.TestCheckResourceAttrSet(resourceName, "id"), resource.TestCheckResourceAttrSet(resourceName, "entries.#"), resource.TestCheckResourceAttrSet(resourceName, "created_at"), @@ -133,9 +133,9 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { 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.TestCheckResourceAttr(resourceName, "tags.0", "test"), - resource.TestCheckResourceAttr(resourceName, "tags.1", "space"), - resource.TestCheckResourceAttr(resourceName, "tags.2", "updated"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "test"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "space"), + resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "updated"), ), }, }, From 308efad97dce977e7315fd7540af0b5c26cf6df4 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 09:48:35 -0700 Subject: [PATCH 09/41] Extract comment schema into helper --- .../kibana/security_exception_item/models.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 38168b7b7..09a24bfc9 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -539,6 +539,14 @@ func getNestedEntryAttrTypes() map[string]attr.Type { } } +// 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, + } +} + // toCreateRequest converts the Terraform model to API create request func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.CreateExceptionListItemJSONRequestBody, diag.Diagnostics) { var diags diag.Diagnostics @@ -807,19 +815,13 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit } } list, d := types.ListValueFrom(ctx, types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "comment": types.StringType, - }, + AttrTypes: getCommentAttrTypes(), }, comments) diags.Append(d...) m.Comments = list } else { m.Comments = types.ListNull(types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "comment": types.StringType, - }, + AttrTypes: getCommentAttrTypes(), }) } From 58c4fb703e17e1ded5c1c4b0f1b96b9c4f2f8f12 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 09:48:48 -0700 Subject: [PATCH 10/41] Simplify os_types handling --- internal/kibana/security_exception_item/models.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 09a24bfc9..02b23b0f3 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -768,11 +768,7 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit // Set optional os_types if apiResp.OsTypes != nil && len(*apiResp.OsTypes) > 0 { - osTypes := make([]string, len(*apiResp.OsTypes)) - for i, osType := range *apiResp.OsTypes { - osTypes[i] = string(osType) - } - set, d := types.SetValueFrom(ctx, types.StringType, osTypes) + set, d := types.SetValueFrom(ctx, types.StringType, *apiResp.OsTypes) diags.Append(d...) m.OsTypes = set } else { From 00f815d2d314673356d86f095c9874c25fd050ff Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 11:21:21 -0700 Subject: [PATCH 11/41] Add complex usage test --- .../security_exception_item/acc_test.go | 47 +++++++++++++++++++ .../kibana/security_exception_item/models.go | 19 ++++---- .../kibana/security_exception_item/schema.go | 3 ++ .../complex_create/main.tf | 33 +++++++++++++ .../complex_update/main.tf | 33 +++++++++++++ 5 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_create/main.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update/main.tf diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 45f691197..a2b683add 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -472,3 +472,50 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, }) } + +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]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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"), + ), + }, + }, + }) +} diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 02b23b0f3..a3bd3bf86 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -10,6 +10,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/elastic/terraform-provider-elasticstack/internal/utils/typeutils" "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" @@ -31,7 +32,7 @@ type ExceptionItemModel struct { Meta jsontypes.Normalized `tfsdk:"meta"` Entries types.List `tfsdk:"entries"` Comments types.List `tfsdk:"comments"` - ExpireTime types.String `tfsdk:"expire_time"` + ExpireTime timetypes.RFC3339 `tfsdk:"expire_time"` CreatedAt types.String `tfsdk:"created_at"` CreatedBy types.String `tfsdk:"created_by"` UpdatedAt types.String `tfsdk:"updated_at"` @@ -635,9 +636,9 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create // Set optional expire_time if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { - expireTime, err := time.Parse(time.RFC3339, m.ExpireTime.ValueString()) - if err != nil { - diags.AddError("Failed to parse expire_time", err.Error()) + expireTime, d := m.ExpireTime.ValueRFC3339Time() + diags.Append(d...) + if diags.HasError() { return nil, diags } expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) @@ -730,9 +731,9 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str // Set optional expire_time if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { - expireTime, err := time.Parse(time.RFC3339, m.ExpireTime.ValueString()) - if err != nil { - diags.AddError("Failed to parse expire_time", err.Error()) + expireTime, d := m.ExpireTime.ValueRFC3339Time() + diags.Append(d...) + if diags.HasError() { return nil, diags } expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) @@ -761,9 +762,9 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit // Set optional expire_time if apiResp.ExpireTime != nil { - m.ExpireTime = types.StringValue(time.Time(*apiResp.ExpireTime).Format(time.RFC3339)) + m.ExpireTime = timetypes.NewRFC3339TimeValue(time.Time(*apiResp.ExpireTime)) } else { - m.ExpireTime = types.StringNull() + m.ExpireTime = timetypes.NewRFC3339Null() } // Set optional os_types diff --git a/internal/kibana/security_exception_item/schema.go b/internal/kibana/security_exception_item/schema.go index 4aa16b001..26b37ecba 100644 --- a/internal/kibana/security_exception_item/schema.go +++ b/internal/kibana/security_exception_item/schema.go @@ -6,6 +6,7 @@ import ( "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" @@ -222,6 +223,8 @@ func (r *ExceptionItemResource) Schema(_ context.Context, _ resource.SchemaReque "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.", 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" + }] +} From 2f006fa5ffcb54da404e5ac2cdfbb6d35f267f47 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 11:25:45 -0700 Subject: [PATCH 12/41] Add import test --- .../security_exception_item/acc_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index a2b683add..c620e3be4 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -65,6 +65,23 @@ func TestAccResourceExceptionItem(t *testing.T) { resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "tags.1", "updated"), ), }, + { // Import + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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, + }, }, }) } @@ -138,6 +155,23 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { resource.TestCheckTypeSetElemAttr(resourceName, "tags.*", "updated"), ), }, + { // Import + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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, + }, }, }) } From d30779577d02d73bbb9bf224568abd552868dc04 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 11:33:01 -0700 Subject: [PATCH 13/41] Add check destroy to tests --- .../security_exception_item/acc_test.go | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index c620e3be4..550dcac8e 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -1,23 +1,29 @@ package security_exception_item_test import ( + "context" "fmt" "regexp" "testing" + "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/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 minExceptionItemAPISupport = version.Must(version.NewVersion("7.9.0")) func TestAccResourceExceptionItem(t *testing.T) { resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -94,6 +100,7 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, ProtoV6ProviderFactories: acctest.Providers, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -181,7 +188,8 @@ func TestAccResourceExceptionItemEntryType_Match(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-match-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -207,7 +215,8 @@ func TestAccResourceExceptionItemEntryType_MatchAny(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-match-any-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -237,7 +246,8 @@ func TestAccResourceExceptionItemEntryType_List(t *testing.T) { valueListValue := "192.168.1.1" resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -266,7 +276,8 @@ func TestAccResourceExceptionItemEntryType_Exists(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-exists-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -291,7 +302,8 @@ func TestAccResourceExceptionItemEntryType_Nested(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-nested-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -319,7 +331,8 @@ func TestAccResourceExceptionItemEntryType_Wildcard(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-wildcard-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -345,7 +358,8 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-validation-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ // Test 1: Match entry missing value { @@ -512,7 +526,8 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { itemID := fmt.Sprintf("test-exception-item-complex-%s", uuid.New().String()[:8]) resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), @@ -553,3 +568,46 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { }, }) } + +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, _ := clients.CompositeIdFromStr(rs.Primary.ID) + + // 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 +} From 1b8528f18724eb431255147f8e9c0e0e2bb7a49b Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 11:34:58 -0700 Subject: [PATCH 14/41] Add resource testing standards --- CODING_STANDARDS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 021ecae0d..efa9994b6 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -41,6 +41,7 @@ This document outlines the coding standards and conventions used in the terrafor ## Schema Definitions +- The id field is an internal identifier. It should use the composite id pattern. See - Use custom types to model attribute specific behaviour. - Use [`jsontypes.NormalizedType{}`](https://github.com/hashicorp/terraform-plugin-framework-jsontypes/blob/main/jsontypes/normalized_type.go) custom type for string attributes containing JSON blobs. - Use [`customtypes.DurationType{}`](./internal/utils/customtypes/duration_type.go) for duration-based string attributes. @@ -76,6 +77,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 resoure in another space (if applicable) ## API Client Usage From 31b2ce59f8403a15a3372b9ea183428dcf75c72a Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 11:41:42 -0700 Subject: [PATCH 15/41] Use utils.isKnown --- .../kibana/security_exception_item/models.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index a3bd3bf86..52c852bc3 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -72,7 +72,7 @@ type NestedEntryModel struct { func convertEntriesToAPI(ctx context.Context, entries types.List) (kbapi.SecurityExceptionsAPIExceptionListItemEntryArray, diag.Diagnostics) { var diags diag.Diagnostics - if entries.IsNull() || entries.IsUnknown() { + if !utils.IsKnown(entries) { return nil, diags } @@ -106,7 +106,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc switch entryType { case "match": // Validate required field - if entry.Value.IsNull() || entry.Value.IsUnknown() || entry.Value.ValueString() == "" { + if !utils.IsKnown(entry.Value) || entry.Value.ValueString() == "" { diags.AddError("Invalid Configuration", "Attribute 'value' is required when type is 'match'") return result, diags } @@ -123,7 +123,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc case "match_any": // Validate required field - if entry.Values.IsNull() || entry.Values.IsUnknown() { + if !utils.IsKnown(entry.Values) { diags.AddError("Invalid Configuration", "Attribute 'values' is required when type is 'match_any'") return result, diags } @@ -154,7 +154,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc case "list": // Validate required field - if entry.List.IsNull() || entry.List.IsUnknown() { + if !utils.IsKnown(entry.List) { diags.AddError("Invalid Configuration", "Attribute 'list' is required when type is 'list'") return result, diags } @@ -187,7 +187,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc case "wildcard": // Validate required field - if entry.Value.IsNull() || entry.Value.IsUnknown() || entry.Value.ValueString() == "" { + if !utils.IsKnown(entry.Value) || entry.Value.ValueString() == "" { diags.AddError("Invalid Configuration", "Attribute 'value' is required when type is 'wildcard'") return result, diags } @@ -204,7 +204,7 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc case "nested": // Validate required field - if entry.Entries.IsNull() || entry.Entries.IsUnknown() { + if !utils.IsKnown(entry.Entries) { diags.AddError("Invalid Configuration", "Attribute 'entries' is required when type is 'nested'") return result, diags } @@ -257,7 +257,7 @@ func convertNestedEntryToAPI(ctx context.Context, entry NestedEntryModel) (kbapi switch entryType { case "match": // Validate required field - if entry.Value.IsNull() || entry.Value.IsUnknown() || entry.Value.ValueString() == "" { + 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 } @@ -274,7 +274,7 @@ func convertNestedEntryToAPI(ctx context.Context, entry NestedEntryModel) (kbapi case "match_any": // Validate required field - if entry.Values.IsNull() || entry.Values.IsUnknown() { + if !utils.IsKnown(entry.Values) { diags.AddError("Invalid Configuration", "Attribute 'values' is required for nested entry when type is 'match_any'") return result, diags } From f418a73fe68c0fdd5fca03a42dcb05afb86932cb Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 12:08:21 -0700 Subject: [PATCH 16/41] Refactor type specific handler into their own functions --- .../kibana/security_exception_item/models.go | 686 +++++++++++------- 1 file changed, 418 insertions(+), 268 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 52c852bc3..3807dafea 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -94,6 +94,182 @@ func convertEntriesToAPI(ctx context.Context, entries types.List) (kbapi.Securit 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 @@ -105,141 +281,97 @@ func convertEntryToAPI(ctx context.Context, entry EntryModel) (kbapi.SecurityExc switch entryType { case "match": - // 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 convertMatchEntryToAPI(entry, field, operator) case "match_any": - // 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 - } + 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 + } +} - 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()) - } +// 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 - case "list": - // Validate required field - if !utils.IsKnown(entry.List) { - diags.AddError("Invalid Configuration", "Attribute 'list' is required when type is 'list'") - return result, diags - } + // 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 + } - 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()) - } + 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()) + } - case "exists": - 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 +} - case "wildcard": - // 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 - } +// 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 - 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()) - } + // 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 + } - case "nested": - // Validate required field - if !utils.IsKnown(entry.Entries) { - diags.AddError("Invalid Configuration", "Attribute 'entries' is required when type is 'nested'") - return result, diags - } + values := utils.ListTypeAs[string](ctx, entry.Values, path.Empty(), &diags) + if diags.HasError() { + return result, diags + } - nestedEntries := utils.ListTypeAs[NestedEntryModel](ctx, entry.Entries, 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 + } - if len(nestedEntries) == 0 { - diags.AddError("Invalid Configuration", "Attribute 'entries' must contain at least one entry when type is 'nested'") - 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()) + } - 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) - } + return result, diags +} - 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()) - } +// 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 - default: - diags.AddError("Invalid entry type", fmt.Sprintf("Unknown entry type: %s", entryType)) + 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 @@ -256,68 +388,15 @@ func convertNestedEntryToAPI(ctx context.Context, entry NestedEntryModel) (kbapi switch entryType { case "match": - // 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 convertNestedMatchEntryToAPI(entry, field, operator) case "match_any": - // 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 convertNestedMatchAnyEntryToAPI(ctx, entry, field, operator) case "exists": - 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 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 } - - return result, diags } // convertEntriesFromAPI converts API entry models to Terraform entry models @@ -347,6 +426,106 @@ func convertEntriesFromAPI(ctx context.Context, apiEntries kbapi.SecurityExcepti return list, diags } +// convertMatchOrWildcardEntryFromAPI converts match or wildcard entries from API format +func convertMatchOrWildcardEntryFromAPI(entryMap map[string]interface{}) EntryModel { + var 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()}) + return entry +} + +// convertMatchAnyEntryFromAPI converts match_any entries from API format +func convertMatchAnyEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry EntryModel + + 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 entry, diags +} + +// convertListEntryFromAPI converts list entries from API format +func convertListEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry EntryModel + + 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 entry, diags +} + +// convertExistsEntryFromAPI converts exists entries from API format +func convertExistsEntryFromAPI() EntryModel { + var 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()}) + return entry +} + +// convertNestedEntryFromAPI converts nested entries from API format +func convertNestedEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry EntryModel + + // 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 entry, 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 @@ -382,83 +561,68 @@ func convertEntryFromAPI(ctx context.Context, apiEntry kbapi.SecurityExceptionsA switch entryType { case "match", "wildcard": - 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()}) - + entry = convertMatchOrWildcardEntryFromAPI(entryMap) case "match_any": - 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()}) - + var d diag.Diagnostics + entry, d = convertMatchAnyEntryFromAPI(ctx, entryMap) + diags.Append(d...) case "list": - 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()}) - + var d diag.Diagnostics + entry, d = convertListEntryFromAPI(ctx, entryMap) + diags.Append(d...) case "exists": + entry = convertExistsEntryFromAPI() + case "nested": + var d diag.Diagnostics + entry, d = convertNestedEntryFromAPI(ctx, entryMap) + diags.Append(d...) + } + + return entry, diags +} + +// convertNestedMatchFromMap converts nested match entries from map format +func convertNestedMatchFromMap(entryMap map[string]interface{}) NestedEntryModel { + var 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) - entry.List = types.ObjectNull(getListAttrTypes()) - entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) + } + entry.Values = types.ListNull(types.StringType) + return entry +} - case "nested": - // 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) - } - } +// convertNestedMatchAnyFromMap converts nested match_any entries from map format +func convertNestedMatchAnyFromMap(ctx context.Context, entryMap map[string]interface{}) (NestedEntryModel, diag.Diagnostics) { + var diags diag.Diagnostics + var entry NestedEntryModel + + 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.ObjectType{AttrTypes: getNestedEntryAttrTypes()}, nestedEntries) - diags.Append(d...) - entry.Entries = list - } else { - entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) } - entry.Value = types.StringNull() + list, d := types.ListValueFrom(ctx, types.StringType, strValues) + diags.Append(d...) + entry.Values = list + } else { entry.Values = types.ListNull(types.StringType) - entry.List = types.ObjectNull(getListAttrTypes()) } - + entry.Value = types.StringNull() return entry, diags } +// convertNestedExistsFromMap converts nested exists entries from map format +func convertNestedExistsFromMap() NestedEntryModel { + var entry NestedEntryModel + entry.Value = types.StringNull() + entry.Values = types.ListNull(types.StringType) + return entry +} + // 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 @@ -477,32 +641,18 @@ func convertNestedEntryFromMap(ctx context.Context, entryMap map[string]interfac entryType := entry.Type.ValueString() switch entryType { case "match": - if value, ok := entryMap["value"].(string); ok { - entry.Value = types.StringValue(value) - } else { - entry.Value = types.StringNull() - } - entry.Values = types.ListNull(types.StringType) - + nestedEntry := convertNestedMatchFromMap(entryMap) + entry.Value = nestedEntry.Value + entry.Values = nestedEntry.Values case "match_any": - 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() - + nestedEntry, d := convertNestedMatchAnyFromMap(ctx, entryMap) + diags.Append(d...) + entry.Value = nestedEntry.Value + entry.Values = nestedEntry.Values case "exists": - entry.Value = types.StringNull() - entry.Values = types.ListNull(types.StringType) + nestedEntry := convertNestedExistsFromMap() + entry.Value = nestedEntry.Value + entry.Values = nestedEntry.Values } return entry, diags From b9a79350678ef28a82c43209ef8c86b8374ae434 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 12:11:51 -0700 Subject: [PATCH 17/41] Remove unfinished entry from CODING_STANDARDS --- CODING_STANDARDS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index efa9994b6..8b99969dc 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -40,8 +40,6 @@ This document outlines the coding standards and conventions used in the terrafor - 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 - -- The id field is an internal identifier. It should use the composite id pattern. See - Use custom types to model attribute specific behaviour. - Use [`jsontypes.NormalizedType{}`](https://github.com/hashicorp/terraform-plugin-framework-jsontypes/blob/main/jsontypes/normalized_type.go) custom type for string attributes containing JSON blobs. - Use [`customtypes.DurationType{}`](./internal/utils/customtypes/duration_type.go) for duration-based string attributes. From 7123e608e159947d2349cc3270a2fd34c4f3948e Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 12:18:45 -0700 Subject: [PATCH 18/41] Fix coding standards header --- CODING_STANDARDS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 8b99969dc..788bced5f 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -40,6 +40,7 @@ This document outlines the coding standards and conventions used in the terrafor - 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 + - Use custom types to model attribute specific behaviour. - Use [`jsontypes.NormalizedType{}`](https://github.com/hashicorp/terraform-plugin-framework-jsontypes/blob/main/jsontypes/normalized_type.go) custom type for string attributes containing JSON blobs. - Use [`customtypes.DurationType{}`](./internal/utils/customtypes/duration_type.go) for duration-based string attributes. From 4034088a65408033ad1a4d87203fca3edb7e76b1 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 12:21:18 -0700 Subject: [PATCH 19/41] Cleanup extraneous null checks --- internal/kibana/security_exception_item/models.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 3807dafea..e5c48abfc 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -718,7 +718,7 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional item_id - if utils.IsKnown(m.ItemID) && !m.ItemID.IsNull() { + if utils.IsKnown(m.ItemID) { itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(m.ItemID.ValueString()) req.ItemId = &itemID } @@ -768,7 +768,7 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional comments - if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { + if utils.IsKnown(m.Comments) { comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) if diags.HasError() { return nil, diags @@ -785,7 +785,7 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // Set optional expire_time - if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { + if utils.IsKnown(m.ExpireTime) { expireTime, d := m.ExpireTime.ValueRFC3339Time() diags.Append(d...) if diags.HasError() { @@ -863,7 +863,7 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str } // Set optional comments - if utils.IsKnown(m.Comments) && !m.Comments.IsNull() { + if utils.IsKnown(m.Comments) { comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) if diags.HasError() { return nil, diags @@ -880,7 +880,7 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str } // Set optional expire_time - if utils.IsKnown(m.ExpireTime) && !m.ExpireTime.IsNull() { + if utils.IsKnown(m.ExpireTime) { expireTime, d := m.ExpireTime.ValueRFC3339Time() diags.Append(d...) if diags.HasError() { From 53fa7c395851a0a856a69958b04d931061088093 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 12:45:43 -0700 Subject: [PATCH 20/41] Fix tests --- .../security_exception_item/acc_test.go | 7 +++- .../kibana/security_exception_item/models.go | 36 +++++++------------ .../complex_create/main.tf | 1 + 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 550dcac8e..699993f49 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -10,6 +10,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/acctest" "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/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" @@ -544,6 +545,7 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "expire_time", "2025-12-31T23:59:59Z"), ), }, { @@ -585,7 +587,10 @@ func checkResourceExceptionItemDestroy(s *terraform.State) error { continue } - compId, _ := clients.CompositeIdFromStr(rs.Primary.ID) + 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) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index e5c48abfc..986bf4d54 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -427,8 +427,7 @@ func convertEntriesFromAPI(ctx context.Context, apiEntries kbapi.SecurityExcepti } // convertMatchOrWildcardEntryFromAPI converts match or wildcard entries from API format -func convertMatchOrWildcardEntryFromAPI(entryMap map[string]interface{}) EntryModel { - var entry EntryModel +func convertMatchOrWildcardEntryFromAPI(entryMap map[string]interface{}, entry *EntryModel) { if value, ok := entryMap["value"].(string); ok { entry.Value = types.StringValue(value) } else { @@ -437,13 +436,11 @@ func convertMatchOrWildcardEntryFromAPI(entryMap map[string]interface{}) EntryMo entry.Values = types.ListNull(types.StringType) entry.List = types.ObjectNull(getListAttrTypes()) entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) - return entry } // convertMatchAnyEntryFromAPI converts match_any entries from API format -func convertMatchAnyEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { +func convertMatchAnyEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { var diags diag.Diagnostics - var entry EntryModel if values, ok := entryMap["value"].([]interface{}); ok { strValues := make([]string, 0, len(values)) @@ -461,13 +458,12 @@ func convertMatchAnyEntryFromAPI(ctx context.Context, entryMap map[string]interf entry.Value = types.StringNull() entry.List = types.ObjectNull(getListAttrTypes()) entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) - return entry, diags + return diags } // convertListEntryFromAPI converts list entries from API format -func convertListEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { +func convertListEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { var diags diag.Diagnostics - var entry EntryModel if listData, ok := entryMap["list"].(map[string]interface{}); ok { listModel := EntryListModel{ @@ -483,23 +479,20 @@ func convertListEntryFromAPI(ctx context.Context, entryMap map[string]interface{ entry.Value = types.StringNull() entry.Values = types.ListNull(types.StringType) entry.Entries = types.ListNull(types.ObjectType{AttrTypes: getNestedEntryAttrTypes()}) - return entry, diags + return diags } // convertExistsEntryFromAPI converts exists entries from API format -func convertExistsEntryFromAPI() EntryModel { - var entry EntryModel +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()}) - return entry } // convertNestedEntryFromAPI converts nested entries from API format -func convertNestedEntryFromAPI(ctx context.Context, entryMap map[string]interface{}) (EntryModel, diag.Diagnostics) { +func convertNestedEntryFromAPI(ctx context.Context, entryMap map[string]interface{}, entry *EntryModel) diag.Diagnostics { var diags diag.Diagnostics - var entry EntryModel // Nested entries don't have an operator field in the API entry.Operator = types.StringNull() @@ -523,7 +516,7 @@ func convertNestedEntryFromAPI(ctx context.Context, entryMap map[string]interfac entry.Value = types.StringNull() entry.Values = types.ListNull(types.StringType) entry.List = types.ObjectNull(getListAttrTypes()) - return entry, diags + return diags } // convertEntryFromAPI converts a single API entry to a Terraform entry model @@ -561,20 +554,17 @@ func convertEntryFromAPI(ctx context.Context, apiEntry kbapi.SecurityExceptionsA switch entryType { case "match", "wildcard": - entry = convertMatchOrWildcardEntryFromAPI(entryMap) + convertMatchOrWildcardEntryFromAPI(entryMap, &entry) case "match_any": - var d diag.Diagnostics - entry, d = convertMatchAnyEntryFromAPI(ctx, entryMap) + d := convertMatchAnyEntryFromAPI(ctx, entryMap, &entry) diags.Append(d...) case "list": - var d diag.Diagnostics - entry, d = convertListEntryFromAPI(ctx, entryMap) + d := convertListEntryFromAPI(ctx, entryMap, &entry) diags.Append(d...) case "exists": - entry = convertExistsEntryFromAPI() + convertExistsEntryFromAPI(&entry) case "nested": - var d diag.Diagnostics - entry, d = convertNestedEntryFromAPI(ctx, entryMap) + d := convertNestedEntryFromAPI(ctx, entryMap, &entry) diags.Append(d...) } 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 index 173b01320..ffd38c936 100644 --- 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 @@ -23,6 +23,7 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos"] tags = ["test", "complex"] + expire_time = "2025-12-31T23:59:59Z" entries = [{ type = "match" From 6f7f6f2999fa774c0371b61607854cf7d330368d Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 13:07:58 -0700 Subject: [PATCH 21/41] Add compId --- internal/kibana/security_exception_item/acc_test.go | 1 - internal/kibana/security_exception_item/delete.go | 10 +++++++++- internal/kibana/security_exception_item/models.go | 9 ++++++++- internal/kibana/security_exception_item/read.go | 12 +++++++++++- .../complex_create/main.tf | 1 - internal/kibana/security_exception_item/update.go | 10 +++++++++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 699993f49..905adf3d9 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -545,7 +545,6 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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"), - resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "expire_time", "2025-12-31T23:59:59Z"), ), }, { diff --git a/internal/kibana/security_exception_item/delete.go b/internal/kibana/security_exception_item/delete.go index 3b834b756..c8fca9445 100644 --- a/internal/kibana/security_exception_item/delete.go +++ b/internal/kibana/security_exception_item/delete.go @@ -4,6 +4,7 @@ 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" ) @@ -17,6 +18,13 @@ func (r *ExceptionItemResource) Delete(ctx context.Context, req resource.DeleteR 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()) @@ -24,7 +32,7 @@ func (r *ExceptionItemResource) Delete(ctx context.Context, req resource.DeleteR } // Delete by ID - id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + id := kbapi.SecurityExceptionsAPIExceptionListItemId(compId.ResourceId) params := &kbapi.DeleteExceptionListItemParams{ Id: &id, } diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 986bf4d54..fd8deea2a 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -7,6 +7,7 @@ import ( "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/elastic/terraform-provider-elasticstack/internal/utils/typeutils" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" @@ -887,7 +888,13 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.SecurityExceptionsAPIExceptionListItem) diag.Diagnostics { var diags diag.Diagnostics - m.ID = typeutils.StringishValue(apiResp.Id) + // 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) diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go index a99798c5b..744ae9e74 100644 --- a/internal/kibana/security_exception_item/read.go +++ b/internal/kibana/security_exception_item/read.go @@ -4,8 +4,10 @@ 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" + "github.com/hashicorp/terraform-plugin-framework/types" ) func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { @@ -17,6 +19,14 @@ func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadReque 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()) @@ -24,7 +34,7 @@ func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadReque } // Read by ID - id := kbapi.SecurityExceptionsAPIExceptionListItemId(state.ID.ValueString()) + id := kbapi.SecurityExceptionsAPIExceptionListItemId(compId.ResourceId) params := &kbapi.ReadExceptionListItemParams{ Id: &id, } 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 index ffd38c936..173b01320 100644 --- 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 @@ -23,7 +23,6 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos"] tags = ["test", "complex"] - expire_time = "2025-12-31T23:59:59Z" entries = [{ type = "match" diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go index 0c63d8bca..4f8846ddd 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -4,6 +4,7 @@ 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" ) @@ -17,6 +18,13 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR 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()) @@ -24,7 +32,7 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR } // Build the update request body using model method - body, diags := plan.toUpdateRequest(ctx, plan.ID.ValueString()) + body, diags := plan.toUpdateRequest(ctx, compId.ResourceId) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From f13df8857d141f33ab0c9abe5299095728ad7433 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 15:19:51 -0700 Subject: [PATCH 22/41] Add expire_time to test --- .../security_exception_item/acc_test.go | 32 +++++++++++++++++++ .../create/exception_item.tf | 1 + .../complex_create/main.tf | 1 + .../complex_update/main.tf | 1 + 4 files changed, 35 insertions(+) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 905adf3d9..345622df3 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -545,6 +545,7 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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"), + resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "expire_time", "2026-12-31T23:59:59.001Z"), ), }, { @@ -564,6 +565,37 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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", "2027-12-31T23:59:59.001Z"), + ), + }, + }, + }) +} + +func TestAccResourceExceptionItem_WithExpireTime(t *testing.T) { + listID := fmt.Sprintf("test-exception-list-expire-%s", uuid.New().String()[:8]) + itemID := fmt.Sprintf("test-exception-item-expire-%s", uuid.New().String()[:8]) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceExceptionItemDestroy, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + ProtoV6ProviderFactories: acctest.Providers, + ConfigDirectory: acctest.NamedTestCaseDirectory("complex_with_expire_time"), + 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"), + resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "expire_time"), ), }, }, 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 index 383f279ae..2be2f9180 100644 --- a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf @@ -53,6 +53,7 @@ resource "elasticstack_kibana_security_exception_item" "test" { description = var.description type = var.type namespace_type = var.namespace_type + expire_time = "2026-12-31T23:59:59.001Z" entries = [ { type = "match" 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 index 173b01320..49e085c06 100644 --- 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 @@ -23,6 +23,7 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos"] tags = ["test", "complex"] + expire_time = "2026-12-31T23:59:59.001Z" entries = [{ type = "match" 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 index 0afc9a14e..3954d59c4 100644 --- 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 @@ -23,6 +23,7 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos", "windows"] tags = ["test", "complex", "updated"] + expire_time = "2027-12-31T23:59:59.001Z" entries = [{ type = "match" From ddebd35f7cdb5be431f2c649222555149569e7b8 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 16:21:55 -0700 Subject: [PATCH 23/41] Remove consolidated test --- .../security_exception_item/acc_test.go | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 345622df3..469a44b39 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -572,36 +572,6 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { }) } -func TestAccResourceExceptionItem_WithExpireTime(t *testing.T) { - listID := fmt.Sprintf("test-exception-list-expire-%s", uuid.New().String()[:8]) - itemID := fmt.Sprintf("test-exception-item-expire-%s", uuid.New().String()[:8]) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - CheckDestroy: checkResourceExceptionItemDestroy, - Steps: []resource.TestStep{ - { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), - ProtoV6ProviderFactories: acctest.Providers, - ConfigDirectory: acctest.NamedTestCaseDirectory("complex_with_expire_time"), - 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"), - resource.TestCheckResourceAttrSet("elasticstack_kibana_security_exception_item.test", "expire_time"), - ), - }, - }, - }) -} - func checkResourceExceptionItemDestroy(s *terraform.State) error { client, err := clients.NewAcceptanceTestingClient() if err != nil { From f228296f2b3fab8f0a9183d2e16be85cdf72e49d Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 16:22:25 -0700 Subject: [PATCH 24/41] Support agnostic namespace --- .../security_exception_item/acc_test.go | 59 +++++++++++++++++++ .../kibana/security_exception_item/create.go | 7 +++ .../kibana/security_exception_item/read.go | 19 ++++++ .../agnostic_create/exception_item.tf | 40 +++++++++++++ .../agnostic_update/exception_item.tf | 40 +++++++++++++ .../kibana/security_exception_item/update.go | 7 +++ 6 files changed, 172 insertions(+) create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_create/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemNamespaceType_Agnostic/agnostic_update/exception_item.tf diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 469a44b39..10780d35b 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -184,6 +184,65 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { }) } +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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + 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]) diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go index db964d82d..211d30873 100644 --- a/internal/kibana/security_exception_item/create.go +++ b/internal/kibana/security_exception_item/create.go @@ -5,6 +5,7 @@ import ( "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" ) @@ -51,6 +52,12 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR 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() { diff --git a/internal/kibana/security_exception_item/read.go b/internal/kibana/security_exception_item/read.go index 744ae9e74..7f35b8f23 100644 --- a/internal/kibana/security_exception_item/read.go +++ b/internal/kibana/security_exception_item/read.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -39,12 +40,30 @@ func (r *ExceptionItemResource) Read(ctx context.Context, req resource.ReadReque 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 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/update.go b/internal/kibana/security_exception_item/update.go index 4f8846ddd..2e5eab1c6 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -59,6 +60,12 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR 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() { From df0dfca7483f0eed051f2259f0b1061bbcf7642d Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 21:08:46 -0700 Subject: [PATCH 25/41] Add version gate --- .../kibana/security_exception_item/create.go | 2 +- .../kibana/security_exception_item/models.go | 29 +++++++++++++++++-- .../kibana/security_exception_item/update.go | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go index 211d30873..4280650b4 100644 --- a/internal/kibana/security_exception_item/create.go +++ b/internal/kibana/security_exception_item/create.go @@ -25,7 +25,7 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR } // Build the request body using model method - body, diags := plan.toCreateRequest(ctx) + body, diags := plan.toCreateRequest(ctx, r.client) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index fd8deea2a..a4b79f280 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -8,8 +8,10 @@ import ( "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" @@ -19,6 +21,9 @@ import ( "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"` @@ -690,7 +695,7 @@ func getCommentAttrTypes() map[string]attr.Type { } // toCreateRequest converts the Terraform model to API create request -func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.CreateExceptionListItemJSONRequestBody, diag.Diagnostics) { +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 @@ -777,6 +782,16 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create // 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 nil, diags + } else if !supported { + diags.AddError("expire_time is unsupported", + fmt.Sprintf("expire_time requires server version %s or higher", MinVersionExpireTime.String())) + return nil, diags + } + expireTime, d := m.ExpireTime.ValueRFC3339Time() diags.Append(d...) if diags.HasError() { @@ -790,7 +805,7 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context) (*kbapi.Create } // toUpdateRequest converts the Terraform model to API update request -func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId string) (*kbapi.UpdateExceptionListItemJSONRequestBody, diag.Diagnostics) { +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 @@ -872,6 +887,16 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str // 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 nil, diags + } else if !supported { + diags.AddError("expire_time is unsupported", + fmt.Sprintf("expire_time requires server version %s or higher", MinVersionExpireTime.String())) + return nil, diags + } + expireTime, d := m.ExpireTime.ValueRFC3339Time() diags.Append(d...) if diags.HasError() { diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go index 2e5eab1c6..67d6f29d3 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -33,7 +33,7 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR } // Build the update request body using model method - body, diags := plan.toUpdateRequest(ctx, compId.ResourceId) + body, diags := plan.toUpdateRequest(ctx, compId.ResourceId, r.client) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return From 576c37f42dee2320499bc4af8de499040acc2497 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 21:43:58 -0700 Subject: [PATCH 26/41] Supprt version check for older kibanas --- internal/clients/api_client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From c146df6a30f1e7f091a23e8c2b87900a5a42c48f Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 22:41:47 -0700 Subject: [PATCH 27/41] Only set expire_time for supporting versions --- .../security_exception_item/acc_test.go | 26 ++++++++++++- .../complex_create/main.tf | 1 - .../complex_update/main.tf | 1 - .../complex_update_expire_time/main.tf | 38 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_Complex/complex_update_expire_time/main.tf diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 10780d35b..0bbb2299e 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "testing" + "time" "github.com/elastic/terraform-provider-elasticstack/generated/kbapi" "github.com/elastic/terraform-provider-elasticstack/internal/acctest" @@ -20,6 +21,7 @@ import ( ) var minExceptionItemAPISupport = version.Must(version.NewVersion("7.9.0")) +var MinVersionExpireTime = version.Must(version.NewVersion("8.7.2")) func TestAccResourceExceptionItem(t *testing.T) { resource.Test(t, resource.TestCase{ @@ -584,6 +586,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { 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]) + expireTime := time.Now().AddDate(2, 0, 0).UTC().Format("2006-01-02T15:04:05.000Z") resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, @@ -604,7 +607,6 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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"), - resource.TestCheckResourceAttr("elasticstack_kibana_security_exception_item.test", "expire_time", "2026-12-31T23:59:59.001Z"), ), }, { @@ -624,7 +626,27 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { 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", "2027-12-31T23:59:59.001Z"), + ), + }, + { + 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), ), }, }, 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 index 49e085c06..173b01320 100644 --- 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 @@ -23,7 +23,6 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos"] tags = ["test", "complex"] - expire_time = "2026-12-31T23:59:59.001Z" entries = [{ type = "match" 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 index 3954d59c4..0afc9a14e 100644 --- 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 @@ -23,7 +23,6 @@ resource "elasticstack_kibana_security_exception_item" "test" { namespace_type = "single" os_types = ["linux", "macos", "windows"] tags = ["test", "complex", "updated"] - expire_time = "2027-12-31T23:59:59.001Z" entries = [{ type = "match" 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..a19f9b02b --- /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" + }] +} From 08d9cc542fc8ac5768d21a9c26d8470ef3d80db0 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 22:45:05 -0700 Subject: [PATCH 28/41] Fmt --- .../complex_update_expire_time/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a19f9b02b..fd01ddc92 100644 --- 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 @@ -8,7 +8,7 @@ variable "item_id" { variable "expire_time" { type = string -} +} resource "elasticstack_kibana_security_exception_list" "test" { name = "test exception list for complex item" From a2566ccee841b01c15f27bc61b5775ef0c198e0b Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Mon, 1 Dec 2025 22:53:42 -0700 Subject: [PATCH 29/41] Remove expire_time from basic test --- .../TestAccResourceExceptionItem/create/exception_item.tf | 1 - 1 file changed, 1 deletion(-) 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 index 2be2f9180..383f279ae 100644 --- a/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf +++ b/internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem/create/exception_item.tf @@ -53,7 +53,6 @@ resource "elasticstack_kibana_security_exception_item" "test" { description = var.description type = var.type namespace_type = var.namespace_type - expire_time = "2026-12-31T23:59:59.001Z" entries = [ { type = "match" From 4e4b02d2d7d0cc67e525ee10662f7b8d97623e96 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 10:27:59 -0700 Subject: [PATCH 30/41] Add custom time serialization --- generated/kbapi/kibana.gen.go | 10 +++++----- generated/kbapi/transform_schema.go | 1 + internal/kibana/security_exception_item/acc_test.go | 6 +++++- internal/kibana/security_exception_item/models.go | 13 ++++++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) 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/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 0bbb2299e..88352bb52 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -586,7 +586,11 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { 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]) - expireTime := time.Now().AddDate(2, 0, 0).UTC().Format("2006-01-02T15:04:05.000Z") + + // 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) }, diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index a4b79f280..002bf5bc0 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -797,7 +797,8 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context, client clients if diags.HasError() { return nil, diags } - expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime.Format("2006-01-02T15:04:05.000Z")) req.ExpireTime = &expireTimeAPI } @@ -902,7 +903,7 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str if diags.HasError() { return nil, diags } - expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime) + expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime.Format("2006-01-02T15:04:05.000Z")) req.ExpireTime = &expireTimeAPI } @@ -934,7 +935,13 @@ func (m *ExceptionItemModel) fromAPI(ctx context.Context, apiResp *kbapi.Securit // Set optional expire_time if apiResp.ExpireTime != nil { - m.ExpireTime = timetypes.NewRFC3339TimeValue(time.Time(*apiResp.ExpireTime)) + 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() } From bba11b93c2a476615cb0a3aabf18a6406a2acf92 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 10:28:48 -0700 Subject: [PATCH 31/41] Run tests with 8.7.2 This includes kibana bug fix: https://github.com/elastic/kibana/pull/159223 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8cb98976..07ed0c72d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,7 +113,7 @@ jobs: - '7.17.13' - '8.5.3' - '8.6.2' - - '8.7.1' + - '8.7.2' - '8.8.2' - '8.9.2' - '8.10.3' From 6159792e36f68e4a11b44afb464860465b4acf5a Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 10:57:04 -0700 Subject: [PATCH 32/41] Skip tests for 8.7.1 --- .github/workflows/test.yml | 2 +- .../security_exception_item/acc_test.go | 68 ++++++++++--------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07ed0c72d..a8cb98976 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,7 +113,7 @@ jobs: - '7.17.13' - '8.5.3' - '8.6.2' - - '8.7.2' + - '8.7.1' - '8.8.2' - '8.9.2' - '8.10.3' diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 88352bb52..cf82deaba 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -20,16 +20,22 @@ import ( "github.com/hashicorp/terraform-plugin-testing/terraform" ) -var minExceptionItemAPISupport = version.Must(version.NewVersion("7.9.0")) var MinVersionExpireTime = version.Must(version.NewVersion("8.7.2")) +// 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.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("create"), ConfigVariables: config.Variables{ @@ -55,7 +61,7 @@ func TestAccResourceExceptionItem(t *testing.T) { ), }, { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("update"), ConfigVariables: config.Variables{ @@ -75,7 +81,7 @@ func TestAccResourceExceptionItem(t *testing.T) { ), }, { // Import - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("update"), ConfigVariables: config.Variables{ @@ -106,7 +112,7 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ConfigDirectory: acctest.NamedTestCaseDirectory("create"), ConfigVariables: config.Variables{ "space_id": config.StringVariable(spaceID), @@ -139,7 +145,7 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { ), }, { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ConfigDirectory: acctest.NamedTestCaseDirectory("update"), ConfigVariables: config.Variables{ "space_id": config.StringVariable(spaceID), @@ -166,7 +172,7 @@ func TestAccResourceExceptionItemWithSpace(t *testing.T) { ), }, { // Import - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ConfigDirectory: acctest.NamedTestCaseDirectory("update"), ConfigVariables: config.Variables{ "space_id": config.StringVariable(spaceID), @@ -195,7 +201,7 @@ func TestAccResourceExceptionItemNamespaceType_Agnostic(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_create"), ConfigVariables: config.Variables{ @@ -213,7 +219,7 @@ func TestAccResourceExceptionItemNamespaceType_Agnostic(t *testing.T) { ), }, { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_update"), ConfigVariables: config.Variables{ @@ -230,7 +236,7 @@ func TestAccResourceExceptionItemNamespaceType_Agnostic(t *testing.T) { ), }, { // Import - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("agnostic_update"), ConfigVariables: config.Variables{ @@ -254,7 +260,7 @@ func TestAccResourceExceptionItemEntryType_Match(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("match"), ConfigVariables: config.Variables{ @@ -281,7 +287,7 @@ func TestAccResourceExceptionItemEntryType_MatchAny(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("match_any"), ConfigVariables: config.Variables{ @@ -312,7 +318,7 @@ func TestAccResourceExceptionItemEntryType_List(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("list"), ConfigVariables: config.Variables{ @@ -342,7 +348,7 @@ func TestAccResourceExceptionItemEntryType_Exists(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("exists"), ConfigVariables: config.Variables{ @@ -368,7 +374,7 @@ func TestAccResourceExceptionItemEntryType_Nested(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("nested"), ConfigVariables: config.Variables{ @@ -397,7 +403,7 @@ func TestAccResourceExceptionItemEntryType_Wildcard(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("wildcard"), ConfigVariables: config.Variables{ @@ -425,7 +431,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { Steps: []resource.TestStep{ // Test 1: Match entry missing value { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_missing_value"), ConfigVariables: config.Variables{ @@ -437,7 +443,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 2: Match entry missing operator { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_missing_operator"), ConfigVariables: config.Variables{ @@ -449,7 +455,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 3: Wildcard entry missing value { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_wildcard_missing_value"), ConfigVariables: config.Variables{ @@ -461,7 +467,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 4: MatchAny entry missing values { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_any_missing_values"), ConfigVariables: config.Variables{ @@ -473,7 +479,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 5: MatchAny entry missing operator { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_match_any_missing_operator"), ConfigVariables: config.Variables{ @@ -485,7 +491,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 6: List entry missing list object { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_object"), ConfigVariables: config.Variables{ @@ -497,7 +503,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 7: List entry missing list.id { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_id"), ConfigVariables: config.Variables{ @@ -509,7 +515,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 8: List entry missing list.type { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_list_missing_list_type"), ConfigVariables: config.Variables{ @@ -521,7 +527,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 9: Exists entry missing operator { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_exists_missing_operator"), ConfigVariables: config.Variables{ @@ -533,7 +539,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 10: Nested entry missing entries { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_missing_entries"), ConfigVariables: config.Variables{ @@ -545,7 +551,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 11: Nested entry with invalid entry type { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_invalid_entry_type"), ConfigVariables: config.Variables{ @@ -557,7 +563,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 12: Nested match entry missing value { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_entry_missing_value"), ConfigVariables: config.Variables{ @@ -569,7 +575,7 @@ func TestAccResourceExceptionItemValidation(t *testing.T) { }, // Test 13: Nested entry missing operator { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("validation_nested_entry_missing_operator"), ConfigVariables: config.Variables{ @@ -597,7 +603,7 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { CheckDestroy: checkResourceExceptionItemDestroy, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("complex_create"), ConfigVariables: config.Variables{ @@ -614,7 +620,7 @@ func TestAccResourceExceptionItem_Complex(t *testing.T) { ), }, { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minExceptionItemAPISupport), + SkipFunc: versionutils.CheckIfVersionMeetsConstraints(allTestsVersionsConstraint), ProtoV6ProviderFactories: acctest.Providers, ConfigDirectory: acctest.NamedTestCaseDirectory("complex_update"), ConfigVariables: config.Variables{ From dda7bf1b943d36c09a0c5f00a7b83e2ddaf3e8c2 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 11:07:04 -0700 Subject: [PATCH 33/41] Add basic usage spec --- .../security_exception_item/acc_test.go | 71 +++++++++++++++++++ .../basic_create/exception_item.tf | 59 +++++++++++++++ .../basic_update/exception_item.tf | 59 +++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_create/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItem_BasicUsage/basic_update/exception_item.tf diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index cf82deaba..9d1b85f61 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -21,6 +21,7 @@ import ( ) 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 @@ -101,6 +102,76 @@ func TestAccResourceExceptionItem(t *testing.T) { }) } +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" 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 +} From 0652bf969f709bf2d1a1e7208802fc5724759e30 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 11:16:00 -0700 Subject: [PATCH 34/41] Add tests for all nested types --- .../security_exception_item/acc_test.go | 35 ++++++++++++++ .../nested_exists/exception_item.tf | 45 ++++++++++++++++++ .../nested_match_any/exception_item.tf | 46 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_exists/exception_item.tf create mode 100644 internal/kibana/security_exception_item/testdata/TestAccResourceExceptionItemEntryType_Nested/nested_match_any/exception_item.tf diff --git a/internal/kibana/security_exception_item/acc_test.go b/internal/kibana/security_exception_item/acc_test.go index 9d1b85f61..7fa29b10a 100644 --- a/internal/kibana/security_exception_item/acc_test.go +++ b/internal/kibana/security_exception_item/acc_test.go @@ -461,6 +461,41 @@ func TestAccResourceExceptionItemEntryType_Nested(t *testing.T) { 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"), + ), + }, }, }) } 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"] +} From 2ae421df9bf86d8512fbb3e574e8fe6a69590d2b Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 11:23:33 -0700 Subject: [PATCH 35/41] Refactor nested item handling to take references --- .../kibana/security_exception_item/models.go | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index 002bf5bc0..c8fdee049 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -578,21 +578,18 @@ func convertEntryFromAPI(ctx context.Context, apiEntry kbapi.SecurityExceptionsA } // convertNestedMatchFromMap converts nested match entries from map format -func convertNestedMatchFromMap(entryMap map[string]interface{}) NestedEntryModel { - var entry NestedEntryModel +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) - return entry } // convertNestedMatchAnyFromMap converts nested match_any entries from map format -func convertNestedMatchAnyFromMap(ctx context.Context, entryMap map[string]interface{}) (NestedEntryModel, diag.Diagnostics) { +func convertNestedMatchAnyFromMap(ctx context.Context, entryMap map[string]interface{}, entry *NestedEntryModel) diag.Diagnostics { var diags diag.Diagnostics - var entry NestedEntryModel if values, ok := entryMap["value"].([]interface{}); ok { strValues := make([]string, 0, len(values)) @@ -608,15 +605,13 @@ func convertNestedMatchAnyFromMap(ctx context.Context, entryMap map[string]inter entry.Values = types.ListNull(types.StringType) } entry.Value = types.StringNull() - return entry, diags + return diags } // convertNestedExistsFromMap converts nested exists entries from map format -func convertNestedExistsFromMap() NestedEntryModel { - var entry NestedEntryModel +func convertNestedExistsFromMap(entry *NestedEntryModel) { entry.Value = types.StringNull() entry.Values = types.ListNull(types.StringType) - return entry } // convertNestedEntryFromMap converts a map representation of nested entry to a model @@ -637,18 +632,12 @@ func convertNestedEntryFromMap(ctx context.Context, entryMap map[string]interfac entryType := entry.Type.ValueString() switch entryType { case "match": - nestedEntry := convertNestedMatchFromMap(entryMap) - entry.Value = nestedEntry.Value - entry.Values = nestedEntry.Values + convertNestedMatchFromMap(entryMap, &entry) case "match_any": - nestedEntry, d := convertNestedMatchAnyFromMap(ctx, entryMap) + d := convertNestedMatchAnyFromMap(ctx, entryMap, &entry) diags.Append(d...) - entry.Value = nestedEntry.Value - entry.Values = nestedEntry.Values case "exists": - nestedEntry := convertNestedExistsFromMap() - entry.Value = nestedEntry.Value - entry.Values = nestedEntry.Values + convertNestedExistsFromMap(&entry) } return entry, diags From bfc355701a8ad394f3c2c78a1b1a60185ba36c7f Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:36:45 -0700 Subject: [PATCH 36/41] Update internal/kibana/security_exception_item/update.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/kibana/security_exception_item/update.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/kibana/security_exception_item/update.go b/internal/kibana/security_exception_item/update.go index 67d6f29d3..c762dbabe 100644 --- a/internal/kibana/security_exception_item/update.go +++ b/internal/kibana/security_exception_item/update.go @@ -51,10 +51,8 @@ func (r *ExceptionItemResource) Update(ctx context.Context, req resource.UpdateR 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. - */ + // 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), From 321987a6837892830e374df8fe3e1487e55e6a37 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:37:15 -0700 Subject: [PATCH 37/41] Update internal/kibana/security_exception_item/create.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/kibana/security_exception_item/create.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/kibana/security_exception_item/create.go b/internal/kibana/security_exception_item/create.go index 4280650b4..92504c12c 100644 --- a/internal/kibana/security_exception_item/create.go +++ b/internal/kibana/security_exception_item/create.go @@ -43,10 +43,9 @@ func (r *ExceptionItemResource) Create(ctx context.Context, req resource.CreateR 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. - */ + // 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), From 2936a3d7a74445c53a54216e2de484b6a60eceac Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:37:33 -0700 Subject: [PATCH 38/41] Update CODING_STANDARDS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 788bced5f..63af2c4ce 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -36,7 +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 eg `types.StringValue(string(apiResp.Id))`. Use `typeutils.StringishPointerValue` for pointers + - `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 From 23f2de2ccc0df27e5e2c710ec3501cc8ac6f6cc7 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:37:51 -0700 Subject: [PATCH 39/41] Update CODING_STANDARDS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 63af2c4ce..fd09be599 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -81,7 +81,7 @@ This document outlines the coding standards and conventions used in the terrafor - Updating a resource - Deleting a resource - Importing a resource - - Creating a resoure in another space (if applicable) + - Creating a resource in another space (if applicable) ## API Client Usage From f7fd99e891b9030fa3f94d4a34b8eab353a37d85 Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:42:29 -0700 Subject: [PATCH 40/41] Update os_types handling --- .../kibana/security_exception_item/models.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index c8fdee049..f57d80a97 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -716,16 +716,12 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context, client clients // Set optional os_types if utils.IsKnown(m.OsTypes) { - osTypes := utils.SetTypeAs[string](ctx, m.OsTypes, path.Empty(), &diags) + osTypes := utils.SetTypeAs[kbapi.SecurityExceptionsAPIExceptionListOsType](ctx, m.OsTypes, path.Empty(), &diags) if diags.HasError() { return nil, diags } if len(osTypes) > 0 { - osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) - for i, osType := range osTypes { - osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) - } - req.OsTypes = &osTypesArray + req.OsTypes = &osTypes } } @@ -822,16 +818,12 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str // Set optional os_types if utils.IsKnown(m.OsTypes) { - osTypes := utils.SetTypeAs[string](ctx, m.OsTypes, path.Empty(), &diags) + osTypes := utils.SetTypeAs[kbapi.SecurityExceptionsAPIExceptionListOsType](ctx, m.OsTypes, path.Empty(), &diags) if diags.HasError() { return nil, diags } if len(osTypes) > 0 { - osTypesArray := make(kbapi.SecurityExceptionsAPIExceptionListItemOsTypeArray, len(osTypes)) - for i, osType := range osTypes { - osTypesArray[i] = kbapi.SecurityExceptionsAPIExceptionListOsType(osType) - } - req.OsTypes = &osTypesArray + req.OsTypes = &osTypes } } From a4da71b6f082ad86a35e5ac26b21e362befb8e4d Mon Sep 17 00:00:00 2001 From: Nick Benoit Date: Tue, 2 Dec 2025 15:52:04 -0700 Subject: [PATCH 41/41] Extract shared code to setCommonProps --- .../kibana/security_exception_item/models.go | 295 +++++++++++------- 1 file changed, 177 insertions(+), 118 deletions(-) diff --git a/internal/kibana/security_exception_item/models.go b/internal/kibana/security_exception_item/models.go index f57d80a97..d3a7dc415 100644 --- a/internal/kibana/security_exception_item/models.go +++ b/internal/kibana/security_exception_item/models.go @@ -683,57 +683,55 @@ func getCommentAttrTypes() map[string]attr.Type { } } -// 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 +// 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...) - 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, - } + return entries +} - // Set optional item_id - if utils.IsKnown(m.ItemID) { - itemID := kbapi.SecurityExceptionsAPIExceptionListItemHumanId(m.ItemID.ValueString()) - req.ItemId = &itemID - } +// 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()) - req.NamespaceType = &nsType + *props.NamespaceType = nsType } // Set optional os_types if utils.IsKnown(m.OsTypes) { - osTypes := utils.SetTypeAs[kbapi.SecurityExceptionsAPIExceptionListOsType](ctx, m.OsTypes, path.Empty(), &diags) + osTypes := utils.SetTypeAs[kbapi.SecurityExceptionsAPIExceptionListOsType](ctx, m.OsTypes, path.Empty(), diags) if diags.HasError() { - return nil, diags + return } if len(osTypes) > 0 { - req.OsTypes = &osTypes + *props.OsTypes = osTypes } } // Set optional tags if utils.IsKnown(m.Tags) { - tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), &diags) + tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), diags) if diags.HasError() { - return nil, diags + return } if len(tags) > 0 { tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) - req.Tags = &tagsArray + *props.Tags = tagsArray } } @@ -743,26 +741,9 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context, client clients unmarshalDiags := m.Meta.Unmarshal(&meta) diags.Append(unmarshalDiags...) if diags.HasError() { - return nil, diags - } - req.Meta = &meta - } - - // Set optional comments - if utils.IsKnown(m.Comments) { - comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) - if diags.HasError() { - return nil, diags - } - if len(comments) > 0 { - commentsArray := make(kbapi.SecurityExceptionsAPICreateExceptionListItemCommentArray, len(comments)) - for i, comment := range comments { - commentsArray[i] = kbapi.SecurityExceptionsAPICreateExceptionListItemComment{ - Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), - } - } - req.Comments = &commentsArray + return } + *props.Meta = meta } // Set optional expire_time @@ -770,21 +751,135 @@ func (m *ExceptionItemModel) toCreateRequest(ctx context.Context, client clients // Check version support for expire_time if supported, versionDiags := client.EnforceMinVersion(ctx, MinVersionExpireTime); versionDiags.HasError() { diags.Append(diagutil.FrameworkDiagsFromSDK(versionDiags)...) - return nil, diags + return } else if !supported { diags.AddError("expire_time is unsupported", fmt.Sprintf("expire_time requires server version %s or higher", MinVersionExpireTime.String())) - return nil, diags + return } expireTime, d := m.ExpireTime.ValueRFC3339Time() diags.Append(d...) if diags.HasError() { - return nil, diags + return } expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime.Format("2006-01-02T15:04:05.000Z")) - req.ExpireTime = &expireTimeAPI + *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 @@ -795,8 +890,7 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str var diags diag.Diagnostics // Convert entries from Terraform model to API model - entries, d := convertEntriesToAPI(ctx, m.Entries) - diags.Append(d...) + entries := m.convertEntriesToAPIWithDiags(ctx, &diags) if diags.HasError() { return nil, diags } @@ -810,82 +904,47 @@ func (m *ExceptionItemModel) toUpdateRequest(ctx context.Context, resourceId str Entries: entries, } - // Set optional namespace_type + // 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) { - nsType := kbapi.SecurityExceptionsAPIExceptionNamespaceType(m.NamespaceType.ValueString()) req.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 nil, diags - } - if len(osTypes) > 0 { - req.OsTypes = &osTypes - } + if utils.IsKnown(m.OsTypes) && len(osTypes) > 0 { + req.OsTypes = &osTypes } - - // Set optional tags - if utils.IsKnown(m.Tags) { - tags := utils.SetTypeAs[string](ctx, m.Tags, path.Empty(), &diags) - if diags.HasError() { - return nil, diags - } - if len(tags) > 0 { - tagsArray := kbapi.SecurityExceptionsAPIExceptionListItemTags(tags) - req.Tags = &tagsArray - } + if utils.IsKnown(m.Tags) && len(tags) > 0 { + req.Tags = &tags } - - // Set optional meta if utils.IsKnown(m.Meta) { - var meta kbapi.SecurityExceptionsAPIExceptionListItemMeta - unmarshalDiags := m.Meta.Unmarshal(&meta) - diags.Append(unmarshalDiags...) - if diags.HasError() { - return nil, diags - } req.Meta = &meta } + if utils.IsKnown(m.ExpireTime) { + req.ExpireTime = &expireTime + } // Set optional comments - if utils.IsKnown(m.Comments) { - comments := utils.ListTypeAs[CommentModel](ctx, m.Comments, path.Empty(), &diags) - if diags.HasError() { - return nil, diags - } - if len(comments) > 0 { - commentsArray := make(kbapi.SecurityExceptionsAPIUpdateExceptionListItemCommentArray, len(comments)) - for i, comment := range comments { - commentsArray[i] = kbapi.SecurityExceptionsAPIUpdateExceptionListItemComment{ - Comment: kbapi.SecurityExceptionsAPINonEmptyString(comment.Comment.ValueString()), - } - } - req.Comments = &commentsArray - } + if comments := m.commentsToUpdateAPI(ctx, &diags); comments != nil { + req.Comments = comments } - - // 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 nil, diags - } else if !supported { - diags.AddError("expire_time is unsupported", - fmt.Sprintf("expire_time requires server version %s or higher", MinVersionExpireTime.String())) - return nil, diags - } - - expireTime, d := m.ExpireTime.ValueRFC3339Time() - diags.Append(d...) - if diags.HasError() { - return nil, diags - } - expireTimeAPI := kbapi.SecurityExceptionsAPIExceptionListItemExpireTime(expireTime.Format("2006-01-02T15:04:05.000Z")) - req.ExpireTime = &expireTimeAPI + if diags.HasError() { + return nil, diags } return req, diags