// 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 }