Skip to content

Commit e348b09

Browse files
Add file-based audit logging and reports API
Introduce file-based audit logging and reporting support. - Add internal/audit: file logger (OpenFile/Close) that fans audit lines to stderr and daily .jsonl files; ReadEntries to query entries by time, stage and limit (default 200, max 1000). - Wire audit into cmd/gateway: open audit files on startup and pass audit dir to REST server; default GATEWAY_AUDIT_DIR is .local/audit. - Extend internal/api/rest: Server.WithAuditDir, and new endpoints GET /api/v1/audit (list audit entries) and GET /api/v1/reports (store-backed summary). - Extend internal/config to include AuditDir and load from env. - Add reporting types and interface to internal/store (DaySummary, ReportSummary, Reporter) and implement Summary for SQLite backend (internal/store/sqlite/reports.go). - Update web client and types: add AuditEntry and ReportSummary types and client methods to call the new audit and reports endpoints. These changes enable persisting audit trails to disk and querying aggregated message reports from the store.
1 parent 14d6d4d commit e348b09

8 files changed

Lines changed: 456 additions & 2 deletions

File tree

cmd/gateway/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ func main() {
3434
}
3535
defer st.Close()
3636

37+
// File-based audit logger (fan-out: stderr + daily .jsonl).
38+
auditCloser, err := audit.OpenFile(cfg.AuditDir)
39+
if err != nil {
40+
log.Fatalf("[gateway] audit: %v", err)
41+
}
42+
defer auditCloser.Close()
43+
3744
// Channel registry — load from YAML if configured.
3845
reg := channel.NewRegistry()
3946
if cfg.ChannelsFile != "" {
@@ -48,7 +55,7 @@ func main() {
4855
// HTTP REST API
4956
httpSrv := &http.Server{
5057
Addr: ":" + cfg.HTTPPort,
51-
Handler: rest.New(st, reg),
58+
Handler: rest.New(st, reg).WithAuditDir(cfg.AuditDir),
5259
}
5360
go func() {
5461
log.Printf("[gateway] REST API listening on :%s", cfg.HTTPPort)

internal/api/rest/server.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"strconv"
2727
"time"
2828

29+
"github.com/vagnercazarotto/verifhir-gateway/internal/audit"
2930
"github.com/vagnercazarotto/verifhir-gateway/internal/channel"
3031
"github.com/vagnercazarotto/verifhir-gateway/internal/store"
3132
)
@@ -57,6 +58,7 @@ type MessageResponse struct {
5758
type Server struct {
5859
store store.Store
5960
channels *channel.Registry
61+
auditDir string
6062
mux *http.ServeMux
6163
}
6264

@@ -73,12 +75,22 @@ func New(st store.Store, reg *channel.Registry) *Server {
7375
s.mux.HandleFunc("GET /api/v1/channels/{id}", s.getChannel)
7476
s.mux.HandleFunc("PUT /api/v1/channels/{id}", s.updateChannel)
7577
s.mux.HandleFunc("DELETE /api/v1/channels/{id}", s.deleteChannel)
78+
// audit + reports routes
79+
s.mux.HandleFunc("GET /api/v1/audit", s.listAudit)
80+
s.mux.HandleFunc("GET /api/v1/reports", s.getReports)
7681
// health routes
7782
s.mux.HandleFunc("GET /healthz", s.healthz)
7883
s.mux.HandleFunc("GET /readyz", s.readyz)
7984
return s
8085
}
8186

87+
// WithAuditDir sets the directory from which audit log entries are served.
88+
// Call before the server starts accepting requests.
89+
func (s *Server) WithAuditDir(dir string) *Server {
90+
s.auditDir = dir
91+
return s
92+
}
93+
8294
// ServeHTTP implements http.Handler.
8395
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8496
s.mux.ServeHTTP(w, r)
@@ -246,6 +258,62 @@ func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {
246258
w.WriteHeader(http.StatusNoContent)
247259
}
248260

261+
// listAudit handles GET /api/v1/audit
262+
//
263+
// Query parameters:
264+
// - from — RFC3339 lower bound (inclusive)
265+
// - to — RFC3339 upper bound (inclusive)
266+
// - stage — filter by pipeline stage (ingest|parse|map|score|route)
267+
// - limit — max records (default 200, max 1000)
268+
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
269+
if s.auditDir == "" {
270+
writeError(w, http.StatusNotImplemented, "audit log directory not configured")
271+
return
272+
}
273+
q := r.URL.Query()
274+
limit := 200
275+
if raw := q.Get("limit"); raw != "" {
276+
n, err := strconv.Atoi(raw)
277+
if err != nil || n <= 0 {
278+
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
279+
return
280+
}
281+
if n > 1000 {
282+
n = 1000
283+
}
284+
limit = n
285+
}
286+
entries, err := audit.ReadEntries(s.auditDir, q.Get("from"), q.Get("to"), q.Get("stage"), limit)
287+
if err != nil {
288+
writeError(w, http.StatusBadRequest, err.Error())
289+
return
290+
}
291+
if entries == nil {
292+
entries = []audit.Entry{}
293+
}
294+
writeJSON(w, http.StatusOK, entries)
295+
}
296+
297+
// getReports handles GET /api/v1/reports
298+
//
299+
// Query parameters:
300+
// - from — RFC3339 lower bound (inclusive)
301+
// - to — RFC3339 upper bound (inclusive)
302+
func (s *Server) getReports(w http.ResponseWriter, r *http.Request) {
303+
rep, ok := s.store.(store.Reporter)
304+
if !ok {
305+
writeError(w, http.StatusNotImplemented, "reporting not supported by this store backend")
306+
return
307+
}
308+
q := r.URL.Query()
309+
summary, err := rep.Summary(r.Context(), q.Get("from"), q.Get("to"))
310+
if err != nil {
311+
writeError(w, http.StatusBadRequest, err.Error())
312+
return
313+
}
314+
writeJSON(w, http.StatusOK, summary)
315+
}
316+
249317
func writeJSON(w http.ResponseWriter, status int, v any) {
250318
w.Header().Set("Content-Type", "application/json")
251319
w.WriteHeader(status)

internal/audit/file.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package audit
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"sort"
11+
"strings"
12+
"time"
13+
)
14+
15+
// fileLogger fans audit lines out to stderr and a daily .jsonl file.
16+
type fileLogger struct {
17+
dir string
18+
current *os.File
19+
day string // "YYYY-MM-DD" of the currently open file
20+
prev io.Writer
21+
}
22+
23+
// OpenFile configures the audit package to write JSON lines to both stderr
24+
// and a daily file under dir (created if absent).
25+
// Call Close on the returned io.Closer during shutdown.
26+
func OpenFile(dir string) (io.Closer, error) {
27+
if err := os.MkdirAll(dir, 0o755); err != nil {
28+
return nil, fmt.Errorf("audit: mkdir %q: %w", dir, err)
29+
}
30+
fl := &fileLogger{dir: dir, prev: out}
31+
if err := fl.rotate(); err != nil {
32+
return nil, err
33+
}
34+
out = io.MultiWriter(os.Stderr, fl)
35+
return fl, nil
36+
}
37+
38+
func (fl *fileLogger) rotate() error {
39+
day := time.Now().UTC().Format("2006-01-02")
40+
if fl.day == day && fl.current != nil {
41+
return nil
42+
}
43+
if fl.current != nil {
44+
fl.current.Close()
45+
}
46+
path := filepath.Join(fl.dir, day+".jsonl")
47+
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
48+
if err != nil {
49+
return fmt.Errorf("audit: open %q: %w", path, err)
50+
}
51+
fl.current = f
52+
fl.day = day
53+
return nil
54+
}
55+
56+
func (fl *fileLogger) Write(p []byte) (int, error) {
57+
_ = fl.rotate() // best-effort daily rotation
58+
if fl.current == nil {
59+
return len(p), nil
60+
}
61+
return fl.current.Write(p)
62+
}
63+
64+
// Close restores the previous audit sink and closes the open file.
65+
func (fl *fileLogger) Close() error {
66+
out = fl.prev
67+
if fl.current != nil {
68+
return fl.current.Close()
69+
}
70+
return nil
71+
}
72+
73+
// ReadEntries reads and filters audit entries from all .jsonl files in dir.
74+
// from/to must be RFC3339 timestamps (empty = no bound).
75+
// stage filters by entry stage field (empty = all stages).
76+
// Results are newest-first, capped at limit (0 = 200).
77+
func ReadEntries(dir, from, to, stage string, limit int) ([]Entry, error) {
78+
des, err := os.ReadDir(dir)
79+
if err != nil {
80+
if os.IsNotExist(err) {
81+
return []Entry{}, nil
82+
}
83+
return nil, fmt.Errorf("audit: readdir %q: %w", dir, err)
84+
}
85+
86+
// sort filenames descending so we read newest days first
87+
sort.Slice(des, func(i, j int) bool {
88+
return des[i].Name() > des[j].Name()
89+
})
90+
91+
var fromT, toT time.Time
92+
if from != "" {
93+
fromT, err = time.Parse(time.RFC3339, from)
94+
if err != nil {
95+
return nil, fmt.Errorf("audit: invalid from: %w", err)
96+
}
97+
}
98+
if to != "" {
99+
toT, err = time.Parse(time.RFC3339, to)
100+
if err != nil {
101+
return nil, fmt.Errorf("audit: invalid to: %w", err)
102+
}
103+
}
104+
if limit <= 0 {
105+
limit = 200
106+
}
107+
108+
var results []Entry
109+
for _, de := range des {
110+
if !strings.HasSuffix(de.Name(), ".jsonl") {
111+
continue
112+
}
113+
// quick range prune using the filename date
114+
day := strings.TrimSuffix(de.Name(), ".jsonl")
115+
if !fromT.IsZero() && day < fromT.Format("2006-01-02") {
116+
continue
117+
}
118+
if !toT.IsZero() && day > toT.Format("2006-01-02") {
119+
continue
120+
}
121+
got, err := readJSONL(filepath.Join(dir, de.Name()), fromT, toT, stage)
122+
if err != nil {
123+
continue // best-effort: skip corrupted files
124+
}
125+
results = append(results, got...)
126+
if len(results) >= limit {
127+
break
128+
}
129+
}
130+
if len(results) > limit {
131+
results = results[:limit]
132+
}
133+
return results, nil
134+
}
135+
136+
func readJSONL(path string, from, to time.Time, stage string) ([]Entry, error) {
137+
f, err := os.Open(path)
138+
if err != nil {
139+
return nil, err
140+
}
141+
defer f.Close()
142+
143+
var entries []Entry
144+
sc := bufio.NewScanner(f)
145+
for sc.Scan() {
146+
var e Entry
147+
if err := json.Unmarshal(sc.Bytes(), &e); err != nil {
148+
continue
149+
}
150+
if stage != "" && e.Stage != stage {
151+
continue
152+
}
153+
if !from.IsZero() || !to.IsZero() {
154+
t, err := time.Parse(time.RFC3339, e.Timestamp)
155+
if err != nil {
156+
continue
157+
}
158+
if !from.IsZero() && t.Before(from) {
159+
continue
160+
}
161+
if !to.IsZero() && t.After(to) {
162+
continue
163+
}
164+
}
165+
entries = append(entries, e)
166+
}
167+
// reverse so newest lines in the file come first
168+
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
169+
entries[i], entries[j] = entries[j], entries[i]
170+
}
171+
return entries, sc.Err()
172+
}

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type Config struct {
1010
MLLPAddr string
1111
DBPath string // SQLite file path; ignored when DATABASE_URL is set
1212
ChannelsFile string // optional YAML file with channel definitions
13+
AuditDir string // directory for daily .jsonl audit files
1314
}
1415

1516
func Load() Config {
@@ -28,10 +29,16 @@ func Load() Config {
2829
dbPath = ".local/verifhir.db"
2930
}
3031

32+
auditDir := os.Getenv("GATEWAY_AUDIT_DIR")
33+
if auditDir == "" {
34+
auditDir = ".local/audit"
35+
}
36+
3137
return Config{
3238
HTTPPort: port,
3339
MLLPAddr: mllp,
3440
DBPath: dbPath,
3541
ChannelsFile: os.Getenv("GATEWAY_CHANNELS_FILE"),
42+
AuditDir: auditDir,
3643
}
3744
}

0 commit comments

Comments
 (0)