179 lines
5.7 KiB
Go
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)
|
|
}
|