135 lines
3.7 KiB
Go
135 lines
3.7 KiB
Go
// Package auditlog provides a structured, append-only audit log for the
|
|
// ecommerce platform. Entries are written as JSON lines (one per line) to an
|
|
// on-disk file, and can be read back in reverse-chronological order.
|
|
//
|
|
// The package is designed for GDPR/admin auditing: every mutation of customer
|
|
// data (anonymize, merge, update, export) should be recorded with the action,
|
|
// the actor (admin user or "customer:{id}"), the target profile, and a
|
|
// human-readable detail string that MUST NOT contain PII.
|
|
//
|
|
// Thread-safe: Write uses a mutex so multiple goroutines (HTTP handlers) can
|
|
// log concurrently. Read also locks so a consistent snapshot is returned.
|
|
package auditlog
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Entry is a single audit log line. All times are RFC3339. The Detail field
|
|
// MUST NOT contain PII (personally identifiable information) — use only
|
|
// profile IDs, order references, and action descriptions.
|
|
type Entry struct {
|
|
Timestamp string `json:"timestamp"`
|
|
Action string `json:"action"`
|
|
Actor string `json:"actor,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// Log manages an append-only audit log file.
|
|
type Log struct {
|
|
mu sync.Mutex
|
|
path string
|
|
f *os.File
|
|
}
|
|
|
|
// New opens (or creates) the audit log at the given file path. The file is
|
|
// kept open for appending until Close is called. If the parent directory does
|
|
// not exist, it is created.
|
|
func New(path string) (*Log, error) {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("auditlog: mkdir: %w", err)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auditlog: open: %w", err)
|
|
}
|
|
return &Log{path: path, f: f}, nil
|
|
}
|
|
|
|
// Write appends one audit entry to the log. It is safe for concurrent use.
|
|
func (l *Log) Write(e Entry) error {
|
|
if e.Timestamp == "" {
|
|
e.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
line, err := json.Marshal(e)
|
|
if err != nil {
|
|
return fmt.Errorf("auditlog: marshal: %w", err)
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if _, err := l.f.Write(append(line, '\n')); err != nil {
|
|
return fmt.Errorf("auditlog: write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Read returns up to limit entries from the log in reverse-chronological order
|
|
// (most recent first). If limit <= 0, all entries are returned. The returned
|
|
// entries are a point-in-time snapshot of the file at call time.
|
|
func (l *Log) Read(limit int) ([]Entry, error) {
|
|
l.mu.Lock()
|
|
// Sync so a concurrent Write's data is visible before we read.
|
|
_ = l.f.Sync()
|
|
l.mu.Unlock()
|
|
|
|
f, err := os.Open(l.path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auditlog: read open: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var entries []Entry
|
|
scanner := bufio.NewScanner(f)
|
|
buf := make([]byte, 0, 1024*64)
|
|
scanner.Buffer(buf, 1024*64)
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var e Entry
|
|
if err := json.Unmarshal(line, &e); err != nil {
|
|
// Skip malformed lines.
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("auditlog: scan: %w", err)
|
|
}
|
|
|
|
// Reverse: newest first.
|
|
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
|
entries[i], entries[j] = entries[j], entries[i]
|
|
}
|
|
|
|
// Optionally, sort by timestamp descending for robustness.
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Timestamp > entries[j].Timestamp
|
|
})
|
|
|
|
if limit > 0 && limit < len(entries) {
|
|
entries = entries[:limit]
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// Close releases the audit log file handle.
|
|
func (l *Log) Close() error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.f != nil {
|
|
return l.f.Close()
|
|
}
|
|
return nil
|
|
}
|