fetch_ml/internal/audit/sealed.go
Jeremie Fraeys a981e89005
feat(security): add audit subsystem and tenant isolation
Implement comprehensive audit and security infrastructure:
- Immutable audit logs with platform-specific backends (Linux/Other)
- Sealed log entries with tamper-evident checksums
- Audit alert system for real-time security notifications
- Log rotation with retention policies
- Checkpoint-based audit verification

Add multi-tenant security features:
- Tenant manager with quota enforcement
- Middleware for tenant authentication/authorization
- Per-tenant cryptographic key isolation
- Supply chain security for container verification
- Cross-platform secure file utilities (Unix/Windows)

Add test coverage:
- Unit tests for audit alerts and sealed logs
- Platform-specific audit backend tests
2026-02-26 12:03:45 -05:00

175 lines
4.4 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/jfraeys/fetch_ml/internal/fileutil"
)
// StateEntry represents a sealed checkpoint entry
type StateEntry struct {
Seq uint64 `json:"seq"`
Hash string `json:"hash"`
Timestamp time.Time `json:"ts"`
Type string `json:"type"`
}
// SealedStateManager maintains tamper-evident state checkpoints.
// It writes to an append-only chain file and an overwritten current file.
// The chain file is fsynced before returning to ensure crash safety.
type SealedStateManager struct {
chainFile string
currentFile string
mu sync.Mutex
}
// NewSealedStateManager creates a new sealed state manager
func NewSealedStateManager(chainFile, currentFile string) *SealedStateManager {
return &SealedStateManager{
chainFile: chainFile,
currentFile: currentFile,
}
}
// Checkpoint writes current state to sealed files.
// It writes to the append-only chain file first, fsyncs it, then overwrites the current file.
// This ordering ensures crash safety: the chain file is always the source of truth.
func (ssm *SealedStateManager) Checkpoint(seq uint64, hash string) error {
ssm.mu.Lock()
defer ssm.mu.Unlock()
entry := StateEntry{
Seq: seq,
Hash: hash,
Timestamp: time.Now().UTC(),
Type: "fsync",
}
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshal state entry: %w", err)
}
// Write to append-only chain file first
f, err := os.OpenFile(ssm.chainFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open chain file: %w", err)
}
if _, err := f.Write(append(data, '\n')); err != nil {
f.Close()
return fmt.Errorf("write chain entry: %w", err)
}
// CRITICAL: fsync chain before returning — crash safety
if err := f.Sync(); err != nil {
f.Close()
return fmt.Errorf("sync sealed chain: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close chain file: %w", err)
}
// Overwrite current-state file (fast lookup) with crash safety (fsync)
if err := fileutil.WriteFileSafe(ssm.currentFile, data, 0o600); err != nil {
return fmt.Errorf("write current file: %w", err)
}
return nil
}
// RecoverState reads last valid state from sealed files.
// It tries the current file first (fast path), then falls back to scanning the chain file.
func (ssm *SealedStateManager) RecoverState() (uint64, string, error) {
// Try current file first (fast path)
data, err := os.ReadFile(ssm.currentFile)
if err == nil {
var entry StateEntry
if json.Unmarshal(data, &entry) == nil {
return entry.Seq, entry.Hash, nil
}
}
// Fall back to scanning chain file for last valid entry
return ssm.scanChainFileForLastValid()
}
// scanChainFileForLastValid scans the chain file and returns the last valid entry
func (ssm *SealedStateManager) scanChainFileForLastValid() (uint64, string, error) {
f, err := os.Open(ssm.chainFile)
if err != nil {
if os.IsNotExist(err) {
return 0, "", nil
}
return 0, "", fmt.Errorf("open chain file: %w", err)
}
defer f.Close()
var lastEntry StateEntry
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if line == "" {
continue
}
var entry StateEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
// Corrupted line - log but continue
continue
}
lastEntry = entry
}
if err := scanner.Err(); err != nil {
return 0, "", fmt.Errorf("scan chain file: %w", err)
}
return lastEntry.Seq, lastEntry.Hash, nil
}
// VerifyChainIntegrity checks that the chain file is intact and returns the number of valid entries
func (ssm *SealedStateManager) VerifyChainIntegrity() (int, error) {
f, err := os.Open(ssm.chainFile)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("open chain file: %w", err)
}
defer f.Close()
validCount := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var entry StateEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue // Skip corrupted lines
}
validCount++
}
if err := scanner.Err(); err != nil {
return validCount, fmt.Errorf("scan chain file: %w", err)
}
return validCount, nil
}
// Close is a no-op for SealedStateManager (state is written immediately)
func (ssm *SealedStateManager) Close() error {
return nil
}