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), + ) + } +}