Files
DoGaMa-serv/internal/audit/service.go
T

179 lines
5.7 KiB
Go

// Package audit stores the deliberately small, redacted security audit trail.
package audit
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Event struct {
ID string `json:"id"`
ActorID string `json:"actor_id"`
ActorLabel string `json:"actor_label"`
InstanceID string `json:"instance_id"`
Action string `json:"action"`
Outcome string `json:"outcome"`
OccurredAt time.Time `json:"occurred_at"`
Summary map[string]string `json:"summary"`
}
type Filter struct {
ActorID, InstanceID, Action, Outcome string
Since, Until time.Time
Limit int
}
type Policy struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
type Service struct {
db *sql.DB
now func() time.Time
}
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
func (s *Service) Record(ctx context.Context, event Event) error {
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
return errors.New("invalid audit event")
}
clean := map[string]string{}
for key, value := range event.Summary {
if allowedSummaryKeys[key] && len(value) <= 200 {
clean[key] = value
}
}
body, _ := json.Marshal(clean)
when := event.OccurredAt
if when.IsZero() {
when = s.now().UTC()
}
var actor, instance any
if event.ActorID != "" {
actor = event.ActorID
}
if event.InstanceID != "" {
instance = event.InstanceID
}
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
if err != nil {
return fmt.Errorf("record audit event: %w", err)
}
return nil
}
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
clauses, args := []string{"1=1"}, []any{}
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
if item.value != "" {
clauses = append(clauses, item.column+"=?")
args = append(args, item.value)
}
}
if !f.Since.IsZero() {
clauses = append(clauses, "occurred_at>=?")
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
}
if !f.Until.IsZero() {
clauses = append(clauses, "occurred_at<?")
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
}
func (s *Service) Policy(ctx context.Context) (Policy, error) {
var body string
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
if err != nil {
return Policy{}, err
}
var p Policy
var raw struct {
RetentionDays int `json:"retention_days"`
MaximumCount int `json:"maximum_count"`
}
if err = json.Unmarshal([]byte(body), &raw); err != nil {
return p, err
}
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
return p, nil
}
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
return errors.New("audit policy is out of bounds")
}
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
return err
}
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
if before.IsZero() || before.After(s.now().UTC()) {
return 0, errors.New("invalid audit purge boundary")
}
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
p, err := s.Policy(ctx)
if err != nil {
return 0, err
}
var total int64
if p.RetentionDays > 0 {
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
if e != nil {
return 0, e
}
total += n
}
if p.MaximumCount > 0 {
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
if e != nil {
return total, e
}
n, _ := result.RowsAffected()
total += n
}
return total, nil
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}