Skip to content

Commit 610552f

Browse files
committed
feat: implement compliance orchestrator backend client and evaluation logic
1 parent b8a60d6 commit 610552f

12 files changed

Lines changed: 321 additions & 106 deletions

File tree

plugins/compliance-orchestrator/client/backend.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package client
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -65,19 +66,19 @@ func (c *BackendClient) HealthCheck(ctx context.Context) error {
6566
return nil
6667
}
6768

68-
func (c *BackendClient) GetReportConfigs(ctx context.Context) ([]models.ReportConfig, error) {
69-
url := fmt.Sprintf("%s/api/compliance/report-config?page=0&size=1000&sort=id,asc", c.baseURL)
70-
var reports []models.ReportConfig
69+
func (c *BackendClient) GetControlConfigs(ctx context.Context) ([]models.ControlConfig, error) {
70+
url := fmt.Sprintf("%s/api/compliance/control-config?page=0&size=1000&sort=id,asc", c.baseURL)
71+
var controls []models.ControlConfig
7172

7273
var body, err = c.GetRequest(ctx, url)
7374
if err != nil {
7475
return nil, err
7576
}
7677

77-
if err := json.Unmarshal(body, &reports); err != nil {
78+
if err := json.Unmarshal(body, &controls); err != nil {
7879
return nil, err
7980
}
80-
return reports, nil
81+
return controls, nil
8182
}
8283

8384
func (c *BackendClient) GetActiveIndexPatterns(ctx context.Context) ([]models.IndexPattern, error) {
@@ -120,3 +121,32 @@ func (c *BackendClient) GetRequest(ctx context.Context, url string) ([]byte, err
120121

121122
return body, nil
122123
}
124+
125+
func (b *BackendClient) IndexEvaluationResult(ctx context.Context, index string, doc any) error {
126+
baseURL := plugins.PluginCfg("org.opensearch", false).Get("opensearch").String()
127+
endpoint := fmt.Sprintf("%s/%s/_doc", baseURL, index)
128+
129+
jsonBody, err := json.Marshal(doc)
130+
if err != nil {
131+
return fmt.Errorf("failed to marshal evaluation result: %w", err)
132+
}
133+
134+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonBody))
135+
if err != nil {
136+
return fmt.Errorf("failed to create index request: %w", err)
137+
}
138+
139+
req.Header.Set("Content-Type", "application/json")
140+
141+
resp, err := b.httpClient.Do(req)
142+
if err != nil {
143+
return fmt.Errorf("index request failed: %w", err)
144+
}
145+
defer resp.Body.Close()
146+
147+
if resp.StatusCode >= 300 {
148+
return fmt.Errorf("indexing failed with status %d", resp.StatusCode)
149+
}
150+
151+
return nil
152+
}

plugins/compliance-orchestrator/client/opensearch.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package client
22

33
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
410
"github.com/threatwinds/go-sdk/catcher"
511
sdkos "github.com/threatwinds/go-sdk/os"
612
"github.com/threatwinds/go-sdk/plugins"
@@ -22,3 +28,41 @@ func ConnectOpenSearch() error {
2228

2329
return nil
2430
}
31+
32+
type SQLResponse struct {
33+
Total int `json:"total"`
34+
}
35+
36+
func (b *BackendClient) ExecuteSQLQuery(ctx context.Context, sql string) (int, error) {
37+
baseURL := plugins.PluginCfg("org.opensearch", false).Get("opensearch").String()
38+
sqlEndpoint := fmt.Sprintf("%s/_plugins/_sql", baseURL)
39+
40+
body := map[string]string{
41+
"query": sql,
42+
}
43+
44+
jsonBody, err := json.Marshal(body)
45+
if err != nil {
46+
return 0, fmt.Errorf("failed to marshal SQL body: %w", err)
47+
}
48+
49+
req, err := http.NewRequestWithContext(ctx, "POST", sqlEndpoint, bytes.NewBuffer(jsonBody))
50+
if err != nil {
51+
return 0, fmt.Errorf("failed to create SQL request: %w", err)
52+
}
53+
54+
req.Header.Set("Content-Type", "application/json")
55+
56+
resp, err := b.httpClient.Do(req)
57+
if err != nil {
58+
return 0, fmt.Errorf("SQL request failed: %w", err)
59+
}
60+
defer resp.Body.Close()
61+
62+
var result SQLResponse
63+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
64+
return 0, fmt.Errorf("failed to decode SQL response: %w", err)
65+
}
66+
67+
return result.Total, nil
68+
}

plugins/compliance-orchestrator/evaluator/evaluator.go

Lines changed: 123 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,70 +17,152 @@ func NewEvaluator(backend *client.BackendClient) *Evaluator {
1717
return &Evaluator{backend: backend}
1818
}
1919

20-
func (e *Evaluator) Evaluate(ctx context.Context, cfg models.ReportConfig) (models.Evaluation, error) {
20+
func (e *Evaluator) Evaluate(ctx context.Context, cfg models.ControlConfig) (models.ControlEvaluation, error) {
2121
// 1. Obtener index patterns activos
22-
_, err := e.backend.GetActiveIndexPatterns(ctx)
22+
patterns, err := e.backend.GetActiveIndexPatterns(ctx)
2323
if err != nil {
24-
return models.Evaluation{}, fmt.Errorf("failed to get index patterns: %w", err)
24+
return models.ControlEvaluation{}, fmt.Errorf("failed to get index patterns: %w", err)
2525
}
2626

27-
// 2. Evaluar cada QuerySpec
28-
/*var results []models.QueryResult*/
29-
for _, q := range cfg.Queries {
30-
/*qr := e.evaluateQuery(ctx, q, patterns)
31-
results = append(results, qr)*/
27+
results := make([]models.QueryEvaluation, 0)
28+
applicable := make([]models.QueryEvaluation, 0) // solo queries con indexPattern activo
29+
30+
// 2. Evaluar cada QueryConfig
31+
for _, q := range cfg.QueriesConfigs {
3232
catcher.Info("Evaluating query", map[string]any{
3333
"query_id": q.ID,
3434
})
35-
}
3635

37-
/*final := combineResults(cfg, results)
36+
// 2.1 Si el index pattern NO está activo → NOT_APPLICABLE
37+
if !patternExists(int(q.IndexPatternID), patterns) {
38+
reason := "Index pattern not active"
39+
qr := models.QueryEvaluation{
40+
QueryConfigID: q.ID,
41+
QueryName: q.QueryName,
42+
Hits: 0,
43+
Status: models.QueryStatusNotApplicable,
44+
ErrorMessage: &reason,
45+
}
46+
results = append(results, qr)
47+
continue
48+
}
3849

39-
return final, nil*/
50+
// 2.2 Evaluar query normalmente
51+
qr := e.evaluateQuery(ctx, q)
52+
results = append(results, qr)
4053

41-
return models.Evaluation{}, nil
42-
}
54+
// 2.3 Solo las queries aplicables participan en la estrategia ALL/ANY
55+
if qr.Status != models.QueryStatusNotApplicable {
56+
applicable = append(applicable, qr)
57+
}
58+
}
59+
60+
// 3. Combinar resultados según la estrategia del control
61+
finalStatus := computeControlStatus(cfg.ControlStrategy, applicable)
4362

44-
func (e *Evaluator) evaluateQuery(ctx context.Context, q models.QuerySpec, patterns []models.IndexPattern) models.QueryResult {
63+
// 4. Construir evaluación final
64+
return models.ControlEvaluation{
65+
ControlConfigID: cfg.ID,
66+
ControlName: cfg.ControlName,
67+
Status: finalStatus,
68+
QueryEvaluations: results,
69+
}, nil
70+
}
4571

46-
/*if !patternExists(q.IndexPatternID, patterns) {
47-
return models.QueryResult{
48-
QueryID: int(q.ID),
49-
Status: models.StatusNotApplicable,
50-
Reason: "Index pattern not active",
72+
func (e *Evaluator) evaluateQuery(ctx context.Context, q models.QueryConfig) models.QueryEvaluation {
73+
// Ejecutar la query SQL real contra OpenSearch
74+
hits, err := e.backend.ExecuteSQLQuery(ctx, q.SQLQuery)
75+
if err != nil {
76+
msg := fmt.Sprintf("query execution failed: %v", err)
77+
return models.QueryEvaluation{
78+
QueryConfigID: q.ID,
79+
QueryName: q.QueryName,
80+
Hits: 0,
81+
Status: models.QueryStatusError,
82+
ErrorMessage: &msg,
5183
}
52-
}*/
84+
}
85+
86+
// Evaluar la regla con los hits obtenidos
87+
status, errMsg := evaluateQueryRule(q, hits)
5388

54-
return models.QueryResult{
55-
QueryID: int(q.ID),
56-
Status: models.StatusCompliant,
57-
Reason: "Query executed successfully (placeholder)",
89+
return models.QueryEvaluation{
90+
QueryConfigID: q.ID,
91+
QueryName: q.QueryName,
92+
Hits: int64(hits),
93+
Status: status,
94+
ErrorMessage: errMsg,
5895
}
5996
}
6097

61-
func patternExists(pattern int, active []models.IndexPattern) bool {
62-
for _, p := range active {
63-
if p.ID == pattern && p.Active {
64-
return true
98+
func evaluateQueryRule(q models.QueryConfig, hits int) (models.QueryEvaluationStatus, *string) {
99+
switch q.EvaluationRule {
100+
101+
case models.NoHitsAllowed:
102+
if hits == 0 {
103+
return models.QueryStatusCompliant, nil
104+
}
105+
return models.QueryStatusNonCompliant, nil
106+
107+
case models.MinHitsRequired:
108+
if q.RuleValue == nil {
109+
msg := "ruleValue is required for MIN_HITS_REQUIRED"
110+
return models.QueryStatusError, &msg
65111
}
112+
if hits >= *q.RuleValue {
113+
return models.QueryStatusCompliant, nil
114+
}
115+
return models.QueryStatusNonCompliant, nil
116+
117+
case models.ThresholdMax:
118+
if q.RuleValue == nil {
119+
msg := "ruleValue is required for THRESHOLD_MAX"
120+
return models.QueryStatusError, &msg
121+
}
122+
if hits <= *q.RuleValue {
123+
return models.QueryStatusCompliant, nil
124+
}
125+
return models.QueryStatusNonCompliant, nil
126+
127+
default:
128+
msg := "unknown evaluation rule"
129+
return models.QueryStatusError, &msg
66130
}
67-
return false
68131
}
69132

70-
func combineResults(cfg models.ReportConfig, results []models.QueryResult) models.Evaluation {
71-
final := models.Evaluation{
72-
ReportID: int(cfg.ID),
73-
Results: results,
74-
}
133+
func computeControlStatus(strategy models.ComplianceStrategy, results []models.QueryEvaluation) models.ControlEvaluationStatus {
75134

76-
// Estrategia simple: si alguna query es NON_COMPLIANT → NON_COMPLIANT
77-
for _, r := range results {
78-
if r.Status == models.StatusNonCompliant {
79-
final.Status = models.StatusNonCompliant
80-
return final
135+
switch strategy {
136+
137+
case models.StrategyAll:
138+
// ALL → todas deben ser COMPLIANT
139+
for _, r := range results {
140+
if r.Status != models.QueryStatusCompliant {
141+
return models.ControlStatusNonCompliant
142+
}
143+
}
144+
return models.ControlStatusCompliant
145+
146+
case models.StrategyAny:
147+
// ANY → basta con que una sea COMPLIANT
148+
for _, r := range results {
149+
if r.Status == models.QueryStatusCompliant {
150+
return models.ControlStatusCompliant
151+
}
81152
}
153+
return models.ControlStatusNonCompliant
154+
155+
default:
156+
// fallback seguro
157+
return models.ControlStatusNonCompliant
82158
}
159+
}
83160

84-
final.Status = models.StatusCompliant
85-
return final
161+
func patternExists(pattern int, active []models.IndexPattern) bool {
162+
for _, p := range active {
163+
if int(p.ID) == pattern && p.Active {
164+
return true
165+
}
166+
}
167+
return false
86168
}
Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
package models
22

3+
type QueryEvaluationStatus string
4+
5+
const (
6+
QueryStatusCompliant QueryEvaluationStatus = "COMPLIANT"
7+
QueryStatusNonCompliant QueryEvaluationStatus = "NON_COMPLIANT"
8+
QueryStatusNotApplicable QueryEvaluationStatus = "NOT_APPLICABLE"
9+
QueryStatusError QueryEvaluationStatus = "ERROR"
10+
)
11+
12+
type ControlEvaluationStatus string
13+
14+
const (
15+
ControlStatusCompliant ControlEvaluationStatus = "COMPLIANT"
16+
ControlStatusNonCompliant ControlEvaluationStatus = "NON_COMPLIANT"
17+
)
18+
19+
type EvaluationRule string
20+
21+
const (
22+
NoHitsAllowed EvaluationRule = "NO_HITS_ALLOWED"
23+
MinHitsRequired EvaluationRule = "MIN_HITS_REQUIRED"
24+
ThresholdMax EvaluationRule = "THRESHOLD_MAX"
25+
)
26+
27+
type ComplianceStrategy string
28+
329
const (
4-
StatusCompliant = "COMPLIANT"
5-
StatusNonCompliant = "NON_COMPLIANT"
6-
StatusNotApplicable = "NOT_APPLICABLE"
7-
StatusError = "ERROR"
30+
StrategyAll ComplianceStrategy = "ALL"
31+
StrategyAny ComplianceStrategy = "ANY"
832
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package models
2+
3+
import "time"
4+
5+
type ControlEvaluation struct {
6+
ControlConfigID int64 `json:"controlConfigId"`
7+
ControlName string `json:"controlName"`
8+
Status ControlEvaluationStatus `json:"status"`
9+
QueryEvaluations []QueryEvaluation `json:"queryevaluations"`
10+
EvaluatedAt time.Time `json:"evaluatedAt"`
11+
}

plugins/compliance-orchestrator/models/evaluation.go

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package models
2+
3+
import "time"
4+
5+
type EvaluationDocument struct {
6+
ControlID int64 `json:"control_id"`
7+
ControlName string `json:"control_name"`
8+
Status ControlEvaluationStatus `json:"status"`
9+
Timestamp time.Time `json:"timestamp"`
10+
QueryEvaluations []QueryEvaluation `json:"query_evaluations"`
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package models
2+
3+
type QueryEvaluation struct {
4+
QueryConfigID int64 `json:"queryConfigId"`
5+
QueryName string `json:"queryName"`
6+
Hits int64 `json:"hits"`
7+
Status QueryEvaluationStatus `json:"status"`
8+
ErrorMessage *string `json:"errorMessage,omitempty"`
9+
Evidence [][]any `json:"evidence,omitempty"`
10+
}

0 commit comments

Comments
 (0)