Files
dockwatch/internal/audit/audit.go
T
jbergner 45ca18b74e
release-tag / release-image (push) Failing after 1m20s
init
2026-08-31 17:09:21 +02:00

100 lines
2.6 KiB
Go

package audit
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
)
type Entry struct {
ID int64 `json:"id"`
UserID *int64 `json:"user_id,omitempty"`
Actor string `json:"actor"`
Action string `json:"action"`
Resource string `json:"resource"`
Detail map[string]any `json:"detail,omitempty"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Status int `json:"status"`
CreatedAt int64 `json:"created_at"`
}
type Service struct{ db *sql.DB }
func New(db *sql.DB) *Service { return &Service{db: db} }
func (s *Service) Log(ctx context.Context, e Entry) error {
if e.CreatedAt == 0 {
e.CreatedAt = time.Now().Unix()
}
b, _ := json.Marshal(e.Detail)
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_log(user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, e.UserID, e.Actor, e.Action, e.Resource, string(b), e.IP, e.UserAgent, e.Status, e.CreatedAt)
return err
}
func (s *Service) List(ctx context.Context, limit, offset int, action string) ([]Entry, error) {
if limit < 1 {
limit = 100
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
q := `SELECT id,user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at FROM audit_log`
args := []any{}
if strings.TrimSpace(action) != "" {
q += ` WHERE action LIKE ?`
args = append(args, "%"+strings.TrimSpace(action)+"%")
}
q += ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
var e Entry
var uid sql.NullInt64
var raw string
if err := rows.Scan(&e.ID, &uid, &e.Actor, &e.Action, &e.Resource, &raw, &e.IP, &e.UserAgent, &e.Status, &e.CreatedAt); err != nil {
return nil, err
}
if uid.Valid {
v := uid.Int64
e.UserID = &v
}
_ = json.Unmarshal([]byte(raw), &e.Detail)
out = append(out, e)
}
return out, rows.Err()
}
// Run periodically prunes old audit records. A retention of 0 keeps the audit
// trail indefinitely.
func (s *Service) Run(ctx context.Context, retentionDays int) {
if retentionDays <= 0 {
<-ctx.Done()
return
}
cleanup := func() {
cut := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix()
_, _ = s.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at<?`, cut)
}
cleanup()
t := time.NewTicker(24 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
cleanup()
}
}
}