Skip to content

Commit 651ca24

Browse files
committed
feat(auditserver): expose MatchFrame library API
1 parent 7b04ca2 commit 651ca24

4 files changed

Lines changed: 253 additions & 42 deletions

File tree

pkg/auditserver/bench_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ func BenchmarkReact(b *testing.B) {
3131
}
3232
}
3333

34+
func BenchmarkMatchFrame(b *testing.B) {
35+
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))
36+
viper.Reset()
37+
viper.Set("rule_groups", []map[string]interface{}{
38+
{
39+
"name": "rg",
40+
"rules": []string{"Auth.PolicyResults.Allowed == true"},
41+
"log_file": map[string]interface{}{
42+
"file_path": "/tmp/test.log",
43+
"max_size": 1,
44+
},
45+
},
46+
})
47+
server, _ := New(logger)
48+
frame := []byte(`{"type":"request","time":"2000-01-01T00:00:00Z","auth":{"policy_results":{"allowed":true}},"request":{},"response":{}}`)
49+
b.ReportAllocs()
50+
b.ResetTimer()
51+
for i := 0; i < b.N; i++ {
52+
_, err := server.MatchFrame(frame)
53+
if err != nil {
54+
b.Fatal(err)
55+
}
56+
}
57+
}
58+
3459
func BenchmarkShouldLog(b *testing.B) {
3560
p, _ := expr.Compile("true", expr.Env(&AuditLog{}))
3661
rg := &RuleGroup{CompiledRules: []CompiledRule{{Program: p}}}

pkg/auditserver/doc.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Package auditserver provides Vault audit log filtering and side-effect processing.
2+
//
3+
// In library mode, callers can construct a server with `New`, then call `MatchFrame`
4+
// to evaluate a raw audit log frame without running a gnet event loop.
5+
//
6+
// Example:
7+
//
8+
// server, err := New(nil)
9+
// if err != nil {
10+
// // handle init error
11+
// }
12+
// result, err := server.MatchFrame([]byte("{\"type\":\"request\",\"time\":\"2024-01-01T00:00:00Z\",\"request\":{\"operation\":\"update\",\"path\":\"secret/data/config\"},\"auth\":{\"policy_results\":{\"allowed\":true}}}"))
13+
// if err != nil {
14+
// // handle parse error
15+
// }
16+
// if result.Matched {
17+
// // result.Log holds the parsed AuditLog
18+
// // result.MatchedGroups lists matched rule group names
19+
// }
20+
package auditserver

pkg/auditserver/server.go

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -150,70 +150,124 @@ type AuditServer struct {
150150
sideTaskSeq atomic.Uint64
151151
}
152152

153-
func (as *AuditServer) handleFrame(frame []byte) gnet.Action {
154-
// Parse the audit log for rule evaluation
153+
// MatchResult describes the outcome of matching a single audit frame.
154+
// It mirrors the rule-group matching behavior used by the runtime event loop,
155+
// returning the decoded log and any rule groups that matched.
156+
type MatchResult struct {
157+
Matched bool
158+
Log AuditLog
159+
MatchedGroups []string
160+
}
161+
162+
type matchResult struct {
163+
Matched bool
164+
Log AuditLog
165+
matchedGroupIndexes []int
166+
}
167+
168+
// MatchFrame evaluates a raw audit log frame against configured rule groups.
169+
// It returns whether any group matched, the decoded audit log, and the names of
170+
// matching rule groups in configured order.
171+
func (as *AuditServer) MatchFrame(frame []byte) (MatchResult, error) {
172+
result, err := as.matchFrame(frame)
173+
if err != nil {
174+
return MatchResult{}, err
175+
}
176+
177+
matchedGroups := make([]string, 0, len(result.matchedGroupIndexes))
178+
for _, idx := range result.matchedGroupIndexes {
179+
matchedGroups = append(matchedGroups, as.ruleGroups[idx].Name)
180+
}
181+
182+
return MatchResult{
183+
Matched: result.Matched,
184+
Log: result.Log,
185+
MatchedGroups: matchedGroups,
186+
}, nil
187+
}
188+
189+
func (as *AuditServer) matchFrame(frame []byte) (matchResult, error) {
155190
auditLog := auditLogPool.Get().(*AuditLog)
156-
*auditLog = AuditLog{} // reset pooled object
191+
*auditLog = AuditLog{}
157192

158193
err := json.Unmarshal(frame, auditLog)
159194
if err != nil {
160-
as.logger.Error("Error parsing audit log", "error", err)
161195
auditLogPool.Put(auditLog)
162-
return gnet.Close
196+
return matchResult{}, err
197+
}
198+
199+
var matchedIndexes []int
200+
for idx := range as.ruleGroups {
201+
if as.ruleGroups[idx].shouldLog(auditLog) {
202+
matchedIndexes = append(matchedIndexes, idx)
203+
}
204+
}
205+
result := matchResult{
206+
Matched: len(matchedIndexes) > 0,
207+
Log: *auditLog,
208+
matchedGroupIndexes: matchedIndexes,
163209
}
164210

165-
matched := false
211+
auditLogPool.Put(auditLog)
212+
return result, nil
213+
}
214+
215+
func (as *AuditServer) handleFrameWithResult(frame []byte, result matchResult) {
216+
166217
var payload []byte
167218
var payloadStr string
168219
payloadReady := false
169220
payloadStrReady := false
170221

171-
// Check each rule group
172-
for _, rg := range as.ruleGroups {
173-
if rg.shouldLog(auditLog) {
174-
matched = true
175-
as.logger.Debug("Matched rule group", "group", rg.Name)
222+
for _, rgIdx := range result.matchedGroupIndexes {
223+
rg := as.ruleGroups[rgIdx]
176224

177-
if rg.Messenger != nil || rg.Forwarder != nil {
178-
if !payloadReady {
179-
payload = append([]byte(nil), frame...)
180-
payloadReady = true
181-
}
182-
if rg.Messenger != nil && !payloadStrReady {
183-
payloadStr = string(payload)
184-
payloadStrReady = true
185-
}
186-
_ = as.enqueueSide(sideTask{
187-
groupName: rg.Name,
188-
payload: payload,
189-
payloadStr: payloadStr,
190-
messenger: rg.Messenger,
191-
forwarder: rg.Forwarder,
192-
})
225+
as.logger.Debug("Matched rule group", "group", rg.Name)
226+
227+
if rg.Messenger != nil || rg.Forwarder != nil {
228+
if !payloadReady {
229+
payload = append([]byte(nil), frame...)
230+
payloadReady = true
231+
}
232+
if rg.Messenger != nil && !payloadStrReady {
233+
payloadStr = string(payload)
234+
payloadStrReady = true
193235
}
236+
_ = as.enqueueSide(sideTask{
237+
groupName: rg.Name,
238+
payload: payload,
239+
payloadStr: payloadStr,
240+
messenger: rg.Messenger,
241+
forwarder: rg.Forwarder,
242+
})
243+
}
194244

195-
// zero‑copy write to log when possible
196-
if rg.Writer != nil {
197-
if _, err := rg.Writer.Write(frame); err != nil {
198-
as.logger.Error("Failed to write audit log", "group", rg.Name, "error", err)
199-
}
245+
if rg.Writer != nil {
246+
if _, err := rg.Writer.Write(frame); err != nil {
247+
as.logger.Error("Failed to write audit log", "group", rg.Name, "error", err)
248+
}
249+
} else {
250+
if payloadStrReady {
251+
rg.Logger.Print(payloadStr)
200252
} else {
201-
if payloadStrReady {
202-
rg.Logger.Print(payloadStr)
203-
} else {
204-
rg.Logger.Print(string(frame))
205-
}
253+
rg.Logger.Print(string(frame))
206254
}
207-
// TODO(JM):Add a flag to prevent logging to multiple groups
208-
// break
209255
}
210256
}
257+
}
211258

212-
auditLogPool.Put(auditLog)
213-
214-
if !matched {
259+
func (as *AuditServer) handleFrame(frame []byte) gnet.Action {
260+
result, err := as.matchFrame(frame)
261+
if err != nil {
262+
as.logger.Error("Error parsing audit log", "error", err)
263+
return gnet.Close
264+
}
265+
if !result.Matched {
215266
return gnet.Close
216267
}
268+
269+
as.handleFrameWithResult(frame, result)
270+
217271
return gnet.None
218272
}
219273

pkg/auditserver/server_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,118 @@ func TestAuditServer_React(t *testing.T) {
269269
}
270270
}
271271

272+
func TestMatchFrame(t *testing.T) {
273+
viper.Reset()
274+
viper.Set("rule_groups", []RuleGroupConfig{
275+
{
276+
Name: "updates",
277+
Rules: []string{
278+
`Request.Operation in ["update", "create"] && Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`,
279+
},
280+
},
281+
})
282+
283+
as, err := New(nil)
284+
require.NoError(t, err)
285+
286+
t.Run("matches and returns parsed log", func(t *testing.T) {
287+
log := []byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"update","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`)
288+
result, err := as.MatchFrame(log)
289+
require.NoError(t, err)
290+
assert.True(t, result.Matched)
291+
assert.Equal(t, "update", result.Log.Request.Operation)
292+
assert.Equal(t, "secret/data/config", result.Log.Request.Path)
293+
assert.Equal(t, []string{"updates"}, result.MatchedGroups)
294+
})
295+
296+
t.Run("non-matching returns false", func(t *testing.T) {
297+
log := []byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`)
298+
result, err := as.MatchFrame(log)
299+
require.NoError(t, err)
300+
assert.False(t, result.Matched)
301+
assert.Empty(t, result.MatchedGroups)
302+
})
303+
304+
t.Run("invalid json returns error", func(t *testing.T) {
305+
_, err := as.MatchFrame([]byte(`{"invalid":`))
306+
require.Error(t, err)
307+
})
308+
}
309+
310+
func TestMatchFrame_UsesMatcherRulesForMatchGroups(t *testing.T) {
311+
viper.Reset()
312+
viper.Set("rule_groups", []RuleGroupConfig{
313+
{
314+
Name: "only_updates",
315+
Rules: []string{`Request.Operation == "update" && Auth.PolicyResults.Allowed == true`},
316+
},
317+
{
318+
Name: "all_reads",
319+
Rules: []string{`Request.Operation == "read" && Auth.PolicyResults.Allowed == true`},
320+
},
321+
})
322+
323+
as, err := New(nil)
324+
require.NoError(t, err)
325+
326+
result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`))
327+
require.NoError(t, err)
328+
assert.True(t, result.Matched)
329+
assert.Equal(t, []string{"all_reads"}, result.MatchedGroups)
330+
}
331+
332+
func TestMatchFrame_ReportsAllMatchingGroupsInOrder(t *testing.T) {
333+
viper.Reset()
334+
viper.Set("rule_groups", []RuleGroupConfig{
335+
{
336+
Name: "first",
337+
Rules: []string{`Request.Operation == "read" && Auth.PolicyResults.Allowed == true`},
338+
},
339+
{
340+
Name: "second",
341+
Rules: []string{`Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`},
342+
},
343+
{
344+
Name: "third",
345+
Rules: []string{`Request.Operation == "read" && Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`},
346+
},
347+
})
348+
349+
as, err := New(nil)
350+
require.NoError(t, err)
351+
352+
result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`))
353+
require.NoError(t, err)
354+
assert.True(t, result.Matched)
355+
assert.Equal(t, []string{"first", "second", "third"}, result.MatchedGroups)
356+
}
357+
358+
func ExampleAuditServer_MatchFrame() {
359+
viper.Reset()
360+
defer viper.Reset()
361+
362+
viper.Set("rule_groups", []RuleGroupConfig{
363+
{
364+
Name: "writes",
365+
Rules: []string{`Request.Operation == "update" && Auth.PolicyResults.Allowed == true`},
366+
},
367+
})
368+
369+
as, err := New(nil)
370+
if err != nil {
371+
panic(err)
372+
}
373+
374+
result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"update","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`))
375+
if err != nil {
376+
panic(err)
377+
}
378+
379+
fmt.Printf("matched=%v groups=%v\n", result.Matched, result.MatchedGroups)
380+
// Output:
381+
// matched=true groups=[writes]
382+
}
383+
272384
func TestNew(t *testing.T) {
273385
// Define rule group configurations with an invalid rule and messenger type
274386
ruleGroupConfigs := []RuleGroupConfig{

0 commit comments

Comments
 (0)