Skip to content

Commit 1aef4bf

Browse files
authored
fix(rule-flood-guard): scope flood detection and rule disabling per tenant (#2495)
1 parent 7c86ce5 commit 1aef4bf

4 files changed

Lines changed: 107 additions & 35 deletions

File tree

‎plugins/rule-flood-guard/README.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ the notification bell, suggesting they mark the alerts as false positives
88

99
It runs as its own independent plugin, separate from `plugins/alerts`.
1010

11+
## Multitenancy
12+
13+
Everything is scoped per tenant:
14+
15+
- **Counting.** Alerts are grouped by tenant, rule name and data source. The
16+
threshold is compared against one tenant's own volume, so two tenants that
17+
are each below it never add up to a flood between them.
18+
- **Disabling.** The rule is disabled only for the tenant that flooded. Every
19+
other tenant keeps it running.
20+
- **Notifying.** The offending tenant is notified, since the disable applies to
21+
them and the remediation is theirs to apply. The platform tenant receives a
22+
copy so the operator keeps instance-wide visibility — unless it is already
23+
the offending tenant, in which case a single notification is sent.
24+
25+
Alerts that carry no tenant are dropped rather than attributed to a default
26+
tenant.
27+
1128
## Configuration
1229

1330
Settings live in `system_plugins_rule-flood-guard.yaml`, in the same pipeline
@@ -26,7 +43,7 @@ plugins:
2643
| Field | Default | Meaning |
2744
|---|---|---|
2845
| `enabled` | `true` | Turns the guard on or off. |
29-
| `threshold` | `50` | How many alerts from the same rule and data source trigger the auto-disable. |
46+
| `threshold` | `50` | How many alerts from the same rule and data source, within a single tenant, trigger the auto-disable. |
3047
| `windowHours` | `24` | Time window used to count alerts. |
3148
| `intervalSeconds` | `300` | How often the guard checks. |
3249

‎plugins/rule-flood-guard/aggregation.go‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ const (
1414
)
1515

1616
type ruleBucket struct {
17+
TenantID string
1718
RuleName string
1819
DataSource string
1920
Count int64
2021
}
2122

23+
// floodGroupFields is the grouping key. tenantId is not a grouping detail, it
24+
// is the isolation: the driver renders these as a literal GROUP BY, so dropping
25+
// it sums every tenant's alerts into one bucket and disables a rule on volume
26+
// no single customer produced.
27+
var floodGroupFields = []string{"tenantId", "name", "dataSource"}
28+
2229
func floodFilters() []store.Filter {
2330
return []store.Filter{
2431
{Field: "status", Op: store.OpEq, Value: statusOpen},
@@ -30,30 +37,52 @@ func floodFilters() []store.Filter {
3037
func searchRuleBuckets(ctx context.Context, window time.Duration) ([]ruleBucket, error) {
3138
now := time.Now().UTC()
3239
scope := store.Scope{
40+
// One pass has to see the whole instance. Isolation comes from grouping
41+
// on tenantId below, not from narrowing this read — do not "fix" this
42+
// to a single tenant.
3343
Tenant: store.AllTenants,
3444
Dataset: datasetAlerts,
3545
From: now.Add(-window),
3646
To: now,
3747
}
3848

3949
groups, err := alertStore.GroupBy(ctx, scope,
40-
[]string{"name", "dataSource"},
50+
floodGroupFields,
4151
floodFilters(),
4252
store.GroupOpts{Limit: maxCombinations},
4353
)
4454
if err != nil {
4555
return nil, err
4656
}
57+
58+
return bucketsFromGroups(groups), nil
59+
}
60+
61+
func bucketsFromGroups(groups []store.Group) []ruleBucket {
4762
buckets := make([]ruleBucket, 0, len(groups))
48-
for _, byName := range groups {
49-
for _, bySource := range byName.Children {
50-
buckets = append(buckets, ruleBucket{
51-
RuleName: byName.Key,
52-
DataSource: bySource.Key,
53-
Count: bySource.Count,
54-
})
63+
64+
var walk func(level []store.Group, path map[string]string)
65+
walk = func(level []store.Group, path map[string]string) {
66+
for _, g := range level {
67+
next := make(map[string]string, len(path)+1)
68+
for k, v := range path {
69+
next[k] = v
70+
}
71+
next[g.Field] = g.Key
72+
73+
if len(g.Children) == 0 {
74+
buckets = append(buckets, ruleBucket{
75+
TenantID: next["tenantId"],
76+
RuleName: next["name"],
77+
DataSource: next["dataSource"],
78+
Count: g.Count,
79+
})
80+
continue
81+
}
82+
walk(g.Children, next)
5583
}
5684
}
85+
walk(groups, map[string]string{})
5786

58-
return buckets, nil
87+
return buckets
5988
}

‎plugins/rule-flood-guard/backend.go‎

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"net/http"
@@ -14,7 +15,14 @@ import (
1415
"github.com/threatwinds/go-sdk/catcher"
1516
)
1617

17-
const notificationMessageTemplate = "Correlation rule '%s' generated over %d open, un-deduplicated alerts from data source '%s' in the last 24h and was automatically disabled to prevent alert flooding. If this volume is expected, use an Alert Tag Rule to mark it 'False positive', or add deduplicateBy/groupBy to the rule, then re-enable it."
18+
// The window is interpolated rather than hardcoded: windowHours is
19+
// configurable, so a fixed "24h" would misreport the period the count covers.
20+
const notificationMessageTemplate = "Correlation rule '%s' generated %d open, un-deduplicated alerts from data source '%s' for tenant '%s' in the last %dh and was automatically disabled for that tenant to prevent alert flooding. If this volume is expected, use an Alert Tag Rule to mark it 'False positive', or add deduplicateBy/groupBy to the rule, then re-enable it."
21+
22+
// tenantHeader scopes every backend call. Without it the middleware treats an
23+
// internal caller as tenantless and the backend falls back to the platform
24+
// tenant, so the disable would land on the wrong tenant's rule list.
25+
const tenantHeader = "X-Tenant-Id"
1826

1927
type backendClient struct {
2028
baseURL string
@@ -36,13 +44,14 @@ type ruleSearchResult struct {
3644
RuleActive bool `json:"ruleActive"`
3745
}
3846

39-
func (c *backendClient) resolveRule(ctx context.Context, ruleName string) ([]ruleSearchResult, error) {
47+
func (c *backendClient) resolveRule(ctx context.Context, tenantID, ruleName string) ([]ruleSearchResult, error) {
4048
endpoint := c.baseURL + "/api/v1/eventprocessing/correlation-rule/search-by-filters?ruleName=" + url.QueryEscape(ruleName)
4149
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
4250
if err != nil {
4351
return nil, err
4452
}
4553
req.Header.Set("X-Internal-Key", c.internalKey)
54+
req.Header.Set(tenantHeader, tenantID)
4655

4756
resp, err := c.httpClient.Do(req)
4857
if err != nil {
@@ -53,7 +62,7 @@ func (c *backendClient) resolveRule(ctx context.Context, ruleName string) ([]rul
5362
if resp.StatusCode >= 400 {
5463
body, _ := io.ReadAll(resp.Body)
5564
return nil, catcher.Error("search-by-filters call returned error status", nil, map[string]any{
56-
"status": resp.StatusCode, "body": string(body), "ruleName": ruleName,
65+
"status": resp.StatusCode, "body": string(body), "ruleName": ruleName, "tenantId": tenantID,
5766
})
5867
}
5968

@@ -70,7 +79,7 @@ func (c *backendClient) resolveRule(ctx context.Context, ruleName string) ([]rul
7079
}
7180
if len(matches) > 1 {
7281
catcher.Warn("rule-flood-guard: ambiguous rule name collision, disabling every exact match", map[string]any{
73-
"ruleName": ruleName, "matches": len(matches),
82+
"ruleName": ruleName, "matches": len(matches), "tenantId": tenantID,
7483
})
7584
}
7685
return matches, nil
@@ -80,8 +89,8 @@ type activateDeactivateResponse struct {
8089
Changed bool `json:"changed"`
8190
}
8291

83-
func (c *backendClient) Deactivate(ctx context.Context, ruleName string) (bool, error) {
84-
matches, err := c.resolveRule(ctx, ruleName)
92+
func (c *backendClient) Deactivate(ctx context.Context, tenantID, ruleName string) (bool, error) {
93+
matches, err := c.resolveRule(ctx, tenantID, ruleName)
8594
if err != nil {
8695
return false, err
8796
}
@@ -94,7 +103,7 @@ func (c *backendClient) Deactivate(ctx context.Context, ruleName string) (bool,
94103
if !matches[i].RuleActive {
95104
continue
96105
}
97-
ruleChanged, err := c.deactivateOne(ctx, ruleName, matches[i].RelPath)
106+
ruleChanged, err := c.deactivateOne(ctx, tenantID, ruleName, matches[i].RelPath)
98107
if err != nil {
99108
return changed, err
100109
}
@@ -103,19 +112,20 @@ func (c *backendClient) Deactivate(ctx context.Context, ruleName string) (bool,
103112
}
104113
}
105114
catcher.Info("rule-flood-guard: exact-match rules processed for deactivation", map[string]any{
106-
"ruleName": ruleName, "matches": len(matches), "changed": changed,
115+
"ruleName": ruleName, "matches": len(matches), "changed": changed, "tenantId": tenantID,
107116
})
108117
return changed, nil
109118
}
110119

111-
func (c *backendClient) deactivateOne(ctx context.Context, ruleName, relPath string) (bool, error) {
120+
func (c *backendClient) deactivateOne(ctx context.Context, tenantID, ruleName, relPath string) (bool, error) {
112121
endpoint := fmt.Sprintf("%s/api/v1/eventprocessing/correlation-rule/activate-deactivate?relPath=%s&active=false",
113122
c.baseURL, url.QueryEscape(relPath))
114123
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, nil)
115124
if err != nil {
116125
return false, err
117126
}
118127
req.Header.Set("X-Internal-Key", c.internalKey)
128+
req.Header.Set(tenantHeader, tenantID)
119129

120130
resp, err := c.httpClient.Do(req)
121131
if err != nil {
@@ -126,7 +136,8 @@ func (c *backendClient) deactivateOne(ctx context.Context, ruleName, relPath str
126136
if resp.StatusCode >= 400 {
127137
body, _ := io.ReadAll(resp.Body)
128138
return false, catcher.Error("activate-deactivate call returned error status", nil, map[string]any{
129-
"status": resp.StatusCode, "body": string(body), "ruleName": ruleName, "relPath": relPath,
139+
"status": resp.StatusCode, "body": string(body), "ruleName": ruleName,
140+
"relPath": relPath, "tenantId": tenantID,
130141
})
131142
}
132143

@@ -143,12 +154,20 @@ type notifyRequest struct {
143154
Message string `json:"message"`
144155
}
145156

146-
// platformTenant receives the flood notifications. A rule that floods is
147-
// deactivated for the whole instance and the rules are not a tenant's to
148-
// re-enable, so the operator is the one who has to know.
157+
// platformTenant is the operator's tenant. It gets a copy of every flood
158+
// notification so the operator keeps the instance-wide visibility they had
159+
// before the disable became per-tenant.
149160
const platformTenant = "ce66672c-e36d-4761-a8c8-90058fee1a24"
150161

151-
func (c *backendClient) Notify(ctx context.Context, message string) error {
162+
func (c *backendClient) Notify(ctx context.Context, tenantID, message string) error {
163+
err := c.notifyTenant(ctx, tenantID, message)
164+
if tenantID != platformTenant {
165+
err = errors.Join(err, c.notifyTenant(ctx, platformTenant, message))
166+
}
167+
return err
168+
}
169+
170+
func (c *backendClient) notifyTenant(ctx context.Context, tenantID, message string) error {
152171
payload, err := json.Marshal(notifyRequest{Source: "SYSTEM", Type: "WARNING", Message: message})
153172
if err != nil {
154173
return err
@@ -159,7 +178,7 @@ func (c *backendClient) Notify(ctx context.Context, message string) error {
159178
return err
160179
}
161180
req.Header.Set("X-Internal-Key", c.internalKey)
162-
req.Header.Set("X-Tenant-Id", platformTenant)
181+
req.Header.Set(tenantHeader, tenantID)
163182
req.Header.Set("Content-Type", "application/json")
164183

165184
resp, err := c.httpClient.Do(req)
@@ -171,12 +190,12 @@ func (c *backendClient) Notify(ctx context.Context, message string) error {
171190
if resp.StatusCode >= 400 {
172191
body, _ := io.ReadAll(resp.Body)
173192
return catcher.Error("notify call returned error status", nil, map[string]any{
174-
"status": resp.StatusCode, "body": string(body),
193+
"status": resp.StatusCode, "body": string(body), "tenantId": tenantID,
175194
})
176195
}
177196
return nil
178197
}
179198

180-
func floodNotificationMessage(ruleName string, threshold int64, dataSource string) string {
181-
return fmt.Sprintf(notificationMessageTemplate, ruleName, threshold, dataSource)
199+
func floodNotificationMessage(tenantID, ruleName string, count int64, dataSource string, windowHours int) string {
200+
return fmt.Sprintf(notificationMessageTemplate, ruleName, count, dataSource, tenantID, windowHours)
182201
}

‎plugins/rule-flood-guard/guard.go‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
type searchFunc func(ctx context.Context, window time.Duration) ([]ruleBucket, error)
1111

1212
type disableNotifier interface {
13-
Deactivate(ctx context.Context, ruleName string) (bool, error)
14-
Notify(ctx context.Context, message string) error
13+
Deactivate(ctx context.Context, tenantID, ruleName string) (bool, error)
14+
Notify(ctx context.Context, tenantID, message string) error
1515
}
1616

1717
type getConfig func() Config
@@ -32,21 +32,28 @@ func evaluateOnce(ctx context.Context, search searchFunc, client disableNotifier
3232
if b.Count <= cfg.Threshold {
3333
continue
3434
}
35+
if b.TenantID == "" {
36+
catcher.Warn("rule-flood-guard: dropping a flooding bucket with no tenant", map[string]any{
37+
"ruleName": b.RuleName, "dataSource": b.DataSource, "count": b.Count,
38+
})
39+
continue
40+
}
3541

36-
changed, err := client.Deactivate(ctx, b.RuleName)
42+
changed, err := client.Deactivate(ctx, b.TenantID, b.RuleName)
3743
if err != nil {
3844
_ = catcher.Error("rule-flood-guard: failed to deactivate rule", err, map[string]any{
39-
"ruleName": b.RuleName, "dataSource": b.DataSource, "count": b.Count,
45+
"tenantId": b.TenantID, "ruleName": b.RuleName,
46+
"dataSource": b.DataSource, "count": b.Count,
4047
})
4148
}
4249
if !changed {
4350
continue
4451
}
4552

46-
msg := floodNotificationMessage(b.RuleName, cfg.Threshold, b.DataSource)
47-
if err := client.Notify(ctx, msg); err != nil {
53+
msg := floodNotificationMessage(b.TenantID, b.RuleName, b.Count, b.DataSource, cfg.WindowHours)
54+
if err := client.Notify(ctx, b.TenantID, msg); err != nil {
4855
_ = catcher.Error("rule-flood-guard: failed to send notification", err, map[string]any{
49-
"ruleName": b.RuleName, "dataSource": b.DataSource,
56+
"tenantId": b.TenantID, "ruleName": b.RuleName, "dataSource": b.DataSource,
5057
})
5158
}
5259
}

0 commit comments

Comments
 (0)