Skip to content

Commit a896e71

Browse files
committed
Add Kibana Security Detection resource
1 parent 5f2154e commit a896e71

File tree

12 files changed

+16041
-6907
lines changed

12 files changed

+16041
-6907
lines changed

generated/kbapi/kibana.gen.go

Lines changed: 15162 additions & 6907 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

generated/kbapi/transform_schema.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ func transformFilterPaths(schema *Schema) {
574574
"/api/apm/settings/agent-configuration": {"get", "put", "delete"},
575575
"/api/actions/connector/{id}": {"get", "put", "post", "delete"},
576576
"/api/actions/connectors": {"get"},
577+
"/api/detection_engine/rules": {"get", "put", "post", "delete"},
577578
}
578579

579580
for path, pathInfo := range schema.Paths {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package detection_rule_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/elastic/terraform-provider-elasticstack/internal/acctest"
7+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
8+
)
9+
10+
func TestAccResourceDetectionRule_basic(t *testing.T) {
11+
resource.Test(t, resource.TestCase{
12+
PreCheck: func() { acctest.PreCheck(t) },
13+
ProtoV6ProviderFactories: acctest.Providers,
14+
Steps: []resource.TestStep{
15+
{
16+
Config: testAccResourceDetectionRule_basic,
17+
Check: resource.ComposeTestCheckFunc(
18+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "name", "Test Detection Rule"),
19+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "description", "A test detection rule"),
20+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "type", "query"),
21+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "severity", "medium"),
22+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "risk_score", "50"),
23+
resource.TestCheckResourceAttr("elasticstack_kibana_security_detection_rule.test", "enabled", "true"),
24+
resource.TestCheckResourceAttrSet("elasticstack_kibana_security_detection_rule.test", "id"),
25+
resource.TestCheckResourceAttrSet("elasticstack_kibana_security_detection_rule.test", "rule_id"),
26+
),
27+
},
28+
},
29+
})
30+
}
31+
32+
const testAccResourceDetectionRule_basic = `
33+
resource "elasticstack_kibana_security_detection_rule" "test" {
34+
name = "Test Detection Rule"
35+
description = "A test detection rule"
36+
type = "query"
37+
severity = "medium"
38+
risk_score = 50
39+
enabled = true
40+
query = "user.name:*"
41+
language = "kuery"
42+
43+
tags = ["test", "terraform"]
44+
}
45+
`
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package detection_rule
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/elastic/terraform-provider-elasticstack/generated/kbapi"
10+
"github.com/google/uuid"
11+
"github.com/hashicorp/terraform-plugin-framework/diag"
12+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
13+
"github.com/hashicorp/terraform-plugin-framework/types"
14+
"github.com/hashicorp/terraform-plugin-log/tflog"
15+
)
16+
17+
// create handles the creation of a new detection rule.
18+
func (r *Resource) create(ctx context.Context, plan *tfsdk.Plan, state *tfsdk.State, diags *diag.Diagnostics) {
19+
var planModel DetectionRuleModel
20+
diags.Append(plan.Get(ctx, &planModel)...)
21+
if diags.HasError() {
22+
return
23+
}
24+
25+
kibanaClient, err := r.client.GetKibanaOapiClient()
26+
if err != nil {
27+
diags.AddError("Unable to get Kibana client", err.Error())
28+
return
29+
}
30+
31+
// Build the create request
32+
createRequest, createDiags := r.buildCreateRequest(ctx, planModel)
33+
diags.Append(createDiags...)
34+
if diags.HasError() {
35+
return
36+
}
37+
38+
tflog.Debug(ctx, "Creating detection rule", map[string]interface{}{
39+
"name": planModel.Name.ValueString(),
40+
"type": planModel.Type.ValueString(),
41+
})
42+
43+
// Create the rule
44+
spaceID := planModel.SpaceID.ValueString()
45+
if spaceID == "" {
46+
spaceID = "default"
47+
}
48+
49+
resp, err := kibanaClient.API.CreateRuleWithResponse(ctx, createRequest,
50+
func(ctx context.Context, req *http.Request) error {
51+
// Add space ID header if not default space
52+
if spaceID != "default" {
53+
req.Header.Set("kbn-space-id", spaceID)
54+
}
55+
return nil
56+
},
57+
)
58+
if err != nil {
59+
diags.AddError("Error creating detection rule", fmt.Sprintf("Request failed: %s", err))
60+
return
61+
}
62+
63+
if resp.StatusCode() != http.StatusOK {
64+
body := "unknown error"
65+
if resp.Body != nil {
66+
body = string(resp.Body)
67+
}
68+
diags.AddError("Error creating detection rule", fmt.Sprintf("API returned status %d: %s", resp.StatusCode(), body))
69+
return
70+
}
71+
72+
// Parse the response
73+
if resp.JSON200 == nil {
74+
diags.AddError("Error creating detection rule", "Empty response from API")
75+
return
76+
}
77+
78+
// Convert response to model
79+
stateModel, convertDiags := r.convertAPIResponseToModel(ctx, *resp.JSON200, spaceID)
80+
diags.Append(convertDiags...)
81+
if diags.HasError() {
82+
return
83+
}
84+
85+
tflog.Debug(ctx, "Successfully created detection rule", map[string]interface{}{
86+
"id": stateModel.ID.ValueString(),
87+
"rule_id": stateModel.RuleID.ValueString(),
88+
})
89+
90+
// Set the state
91+
diags.Append(state.Set(ctx, stateModel)...)
92+
}
93+
94+
// buildCreateRequest builds the API request for creating a detection rule.
95+
func (r *Resource) buildCreateRequest(ctx context.Context, model DetectionRuleModel) (kbapi.CreateRuleJSONRequestBody, diag.Diagnostics) {
96+
var diags diag.Diagnostics
97+
98+
// Generate rule_id if not provided
99+
ruleID := model.RuleID.ValueString()
100+
if ruleID == "" {
101+
ruleID = uuid.New().String()
102+
}
103+
104+
// Build the base request structure
105+
var request kbapi.SecurityDetectionsAPIRuleCreateProps
106+
107+
// Set fields based on rule type
108+
ruleType := model.Type.ValueString()
109+
110+
switch ruleType {
111+
case "query":
112+
queryRule := kbapi.SecurityDetectionsAPIQueryRuleCreateProps{
113+
Type: kbapi.SecurityDetectionsAPIQueryRuleCreatePropsTypeQuery,
114+
Name: model.Name.ValueString(),
115+
Description: model.Description.ValueString(),
116+
RiskScore: int(model.RiskScore.ValueInt64()),
117+
Severity: kbapi.SecurityDetectionsAPISeverity(model.Severity.ValueString()),
118+
}
119+
120+
// Set optional fields
121+
if !model.Query.IsNull() && model.Query.ValueString() != "" {
122+
queryStr := model.Query.ValueString()
123+
queryRule.Query = &queryStr
124+
}
125+
126+
if !model.Language.IsNull() && model.Language.ValueString() != "" {
127+
lang := kbapi.SecurityDetectionsAPIKqlQueryLanguage(model.Language.ValueString())
128+
queryRule.Language = &lang
129+
}
130+
131+
if !model.Enabled.IsNull() {
132+
enabled := model.Enabled.ValueBool()
133+
queryRule.Enabled = &enabled
134+
}
135+
136+
if ruleID != "" {
137+
queryRule.RuleId = &ruleID
138+
}
139+
140+
// Use the FromQueryRuleCreateProps method to set the union
141+
err := request.FromSecurityDetectionsAPIQueryRuleCreateProps(queryRule)
142+
if err != nil {
143+
diags.AddError("Error building query rule request", err.Error())
144+
return request, diags
145+
}
146+
147+
case "eql":
148+
eqlRule := kbapi.SecurityDetectionsAPIEqlRuleCreateProps{
149+
Type: kbapi.SecurityDetectionsAPIEqlRuleCreatePropsTypeEql,
150+
Language: kbapi.SecurityDetectionsAPIEqlQueryLanguageEql,
151+
Name: model.Name.ValueString(),
152+
Description: model.Description.ValueString(),
153+
RiskScore: int(model.RiskScore.ValueInt64()),
154+
Severity: kbapi.SecurityDetectionsAPISeverity(model.Severity.ValueString()),
155+
}
156+
157+
if !model.Query.IsNull() && model.Query.ValueString() != "" {
158+
queryStr := model.Query.ValueString()
159+
eqlRule.Query = queryStr
160+
}
161+
162+
if !model.Enabled.IsNull() {
163+
enabled := model.Enabled.ValueBool()
164+
eqlRule.Enabled = &enabled
165+
}
166+
167+
if ruleID != "" {
168+
eqlRule.RuleId = &ruleID
169+
}
170+
171+
// Use the FromEqlRuleCreateProps method to set the union
172+
err := request.FromSecurityDetectionsAPIEqlRuleCreateProps(eqlRule)
173+
if err != nil {
174+
diags.AddError("Error building EQL rule request", err.Error())
175+
return request, diags
176+
}
177+
178+
default:
179+
diags.AddError("Unsupported rule type", fmt.Sprintf("Rule type '%s' is not yet supported", ruleType))
180+
return request, diags
181+
}
182+
183+
return request, diags
184+
}
185+
186+
// convertAPIResponseToModel converts the API response to the Terraform model.
187+
func (r *Resource) convertAPIResponseToModel(ctx context.Context, response interface{}, spaceID string) (DetectionRuleModel, diag.Diagnostics) {
188+
var diags diag.Diagnostics
189+
var model DetectionRuleModel
190+
191+
// Convert response to JSON for easier processing
192+
responseBytes, err := json.Marshal(response)
193+
if err != nil {
194+
diags.AddError("Error processing API response", fmt.Sprintf("Failed to marshal response: %s", err))
195+
return model, diags
196+
}
197+
198+
// Parse as generic map for flexible handling
199+
var responseMap map[string]interface{}
200+
if err := json.Unmarshal(responseBytes, &responseMap); err != nil {
201+
diags.AddError("Error processing API response", fmt.Sprintf("Failed to unmarshal response: %s", err))
202+
return model, diags
203+
}
204+
205+
// Extract common fields
206+
if id, ok := responseMap["id"].(string); ok {
207+
model.ID = types.StringValue(id)
208+
}
209+
if ruleId, ok := responseMap["rule_id"].(string); ok {
210+
model.RuleID = types.StringValue(ruleId)
211+
}
212+
if name, ok := responseMap["name"].(string); ok {
213+
model.Name = types.StringValue(name)
214+
}
215+
if description, ok := responseMap["description"].(string); ok {
216+
model.Description = types.StringValue(description)
217+
}
218+
if ruleType, ok := responseMap["type"].(string); ok {
219+
model.Type = types.StringValue(ruleType)
220+
}
221+
if enabled, ok := responseMap["enabled"].(bool); ok {
222+
model.Enabled = types.BoolValue(enabled)
223+
}
224+
if riskScore, ok := responseMap["risk_score"].(float64); ok {
225+
model.RiskScore = types.Int64Value(int64(riskScore))
226+
}
227+
if severity, ok := responseMap["severity"].(string); ok {
228+
model.Severity = types.StringValue(severity)
229+
}
230+
231+
// Set space ID
232+
model.SpaceID = types.StringValue(spaceID)
233+
234+
// Set computed fields
235+
if createdAt, ok := responseMap["created_at"].(string); ok {
236+
model.CreatedAt = types.StringValue(createdAt)
237+
}
238+
if createdBy, ok := responseMap["created_by"].(string); ok {
239+
model.CreatedBy = types.StringValue(createdBy)
240+
}
241+
if updatedAt, ok := responseMap["updated_at"].(string); ok {
242+
model.UpdatedAt = types.StringValue(updatedAt)
243+
}
244+
if updatedBy, ok := responseMap["updated_by"].(string); ok {
245+
model.UpdatedBy = types.StringValue(updatedBy)
246+
}
247+
if version, ok := responseMap["version"].(float64); ok {
248+
model.Version = types.Int64Value(int64(version))
249+
}
250+
if revision, ok := responseMap["revision"].(float64); ok {
251+
model.Revision = types.Int64Value(int64(revision))
252+
}
253+
254+
// Handle rule-specific fields
255+
if query, ok := responseMap["query"].(string); ok {
256+
model.Query = types.StringValue(query)
257+
}
258+
if language, ok := responseMap["language"].(string); ok {
259+
model.Language = types.StringValue(language)
260+
}
261+
262+
// Handle arrays - for now, set empty defaults if not present
263+
model.Tags = types.ListNull(types.StringType)
264+
model.References = types.ListNull(types.StringType)
265+
model.FalsePositives = types.ListNull(types.StringType)
266+
model.Author = types.ListNull(types.StringType)
267+
model.Index = types.ListNull(types.StringType)
268+
269+
// Set defaults for other fields
270+
if model.Interval.IsNull() {
271+
model.Interval = types.StringValue("5m")
272+
}
273+
if model.From.IsNull() {
274+
model.From = types.StringValue("now-6m")
275+
}
276+
if model.To.IsNull() {
277+
model.To = types.StringValue("now")
278+
}
279+
if model.MaxSignals.IsNull() {
280+
model.MaxSignals = types.Int64Value(100)
281+
}
282+
283+
return model, diags
284+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package detection_rule
2+
3+
import (
4+
"context"
5+
6+
"github.com/hashicorp/terraform-plugin-framework/diag"
7+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
8+
"github.com/hashicorp/terraform-plugin-log/tflog"
9+
)
10+
11+
// delete handles deleting an existing detection rule.
12+
func (r *Resource) delete(ctx context.Context, state *tfsdk.State, diags *diag.Diagnostics) {
13+
var stateModel DetectionRuleModel
14+
diags.Append(state.Get(ctx, &stateModel)...)
15+
if diags.HasError() {
16+
return
17+
}
18+
19+
tflog.Debug(ctx, "Deleting detection rule", map[string]interface{}{
20+
"id": stateModel.ID.ValueString(),
21+
"rule_id": stateModel.RuleID.ValueString(),
22+
"space_id": stateModel.SpaceID.ValueString(),
23+
})
24+
25+
// TODO: Implement proper delete when DELETE /api/detection_engine/rules/{ruleId} is available in the generated client
26+
// For now, we'll treat this as unsupported and return an error
27+
diags.AddError(
28+
"Delete not yet supported",
29+
"Detection rule deletion is not yet implemented. The generated API client needs to be extended to support the DELETE endpoint.",
30+
)
31+
}

0 commit comments

Comments
 (0)