mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
497 lines
12 KiB
Go
497 lines
12 KiB
Go
package audit
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// SQLiteLoggerConfig configures the SQLite audit logger.
|
|
type SQLiteLoggerConfig struct {
|
|
DataDir string // Directory for audit.db
|
|
CryptoMgr CryptoEncryptor // For encrypting the signing key (optional)
|
|
RetentionDays int // Days to keep events (default: 90, 0 = forever)
|
|
}
|
|
|
|
// SQLiteLogger implements Logger with persistent SQLite storage and HMAC signing.
|
|
type SQLiteLogger struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
dbPath string
|
|
signer *Signer
|
|
webhookDelivery *WebhookDelivery
|
|
retentionDays int
|
|
stopChan chan struct{}
|
|
wg sync.WaitGroup
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
}
|
|
|
|
// NewSQLiteLogger creates a new SQLite-backed audit logger.
|
|
func NewSQLiteLogger(cfg SQLiteLoggerConfig) (*SQLiteLogger, error) {
|
|
if cfg.DataDir == "" {
|
|
return nil, fmt.Errorf("data directory is required")
|
|
}
|
|
|
|
// Ensure directory exists
|
|
auditDir := filepath.Join(cfg.DataDir, "audit")
|
|
if err := os.MkdirAll(auditDir, 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create audit directory: %w", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(auditDir, "audit.db")
|
|
|
|
// Open database with pragmas in DSN so every pool connection is configured
|
|
dsn := dbPath + "?" + url.Values{
|
|
"_pragma": []string{
|
|
"busy_timeout(30000)",
|
|
"journal_mode(WAL)",
|
|
"synchronous(NORMAL)",
|
|
"foreign_keys(ON)",
|
|
"cache_size(-64000)",
|
|
},
|
|
}.Encode()
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open audit database: %w", err)
|
|
}
|
|
|
|
// SQLite works best with a single writer connection
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(0)
|
|
|
|
// Initialize signer
|
|
signer, err := NewSigner(auditDir, cfg.CryptoMgr)
|
|
if err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to initialize audit signer: %w", err)
|
|
}
|
|
|
|
retentionDays := cfg.RetentionDays
|
|
if retentionDays == 0 {
|
|
retentionDays = 90 // Default
|
|
}
|
|
|
|
l := &SQLiteLogger{
|
|
db: db,
|
|
dbPath: dbPath,
|
|
signer: signer,
|
|
retentionDays: retentionDays,
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
// Initialize schema
|
|
if err := l.initSchema(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
|
}
|
|
|
|
// Load webhook URLs from config table
|
|
urls := l.loadWebhookURLs()
|
|
if len(urls) > 0 {
|
|
l.webhookDelivery = NewWebhookDelivery(urls)
|
|
l.webhookDelivery.Start()
|
|
}
|
|
|
|
// Start retention worker if retention is enabled
|
|
if retentionDays > 0 {
|
|
l.wg.Add(1)
|
|
go l.retentionWorker()
|
|
}
|
|
|
|
log.Info().
|
|
Str("dbPath", dbPath).
|
|
Int("retentionDays", retentionDays).
|
|
Bool("signingEnabled", signer.SigningEnabled()).
|
|
Msg("SQLite audit logger initialized")
|
|
|
|
return l, nil
|
|
}
|
|
|
|
// initSchema creates the database tables and runs migrations.
|
|
func (l *SQLiteLogger) initSchema() error {
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp INTEGER NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
user TEXT,
|
|
ip TEXT,
|
|
path TEXT,
|
|
success INTEGER NOT NULL,
|
|
details TEXT,
|
|
signature TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_events(event_type);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_events(user) WHERE user != '';
|
|
CREATE INDEX IF NOT EXISTS idx_audit_success ON audit_events(success);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at INTEGER NOT NULL
|
|
);
|
|
`
|
|
|
|
_, err := l.db.Exec(schema)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create schema: %w", err)
|
|
}
|
|
|
|
// Record schema version
|
|
_, err = l.db.Exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, ?)`,
|
|
time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
// Log records an audit event with HMAC signature.
|
|
func (l *SQLiteLogger) Log(event Event) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// Sign the event
|
|
event.Signature = l.signer.Sign(event)
|
|
|
|
// Insert into database
|
|
success := 0
|
|
if event.Success {
|
|
success = 1
|
|
}
|
|
|
|
_, err := l.db.Exec(`
|
|
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
event.ID,
|
|
event.Timestamp.Unix(),
|
|
event.EventType,
|
|
event.User,
|
|
event.IP,
|
|
event.Path,
|
|
success,
|
|
event.Details,
|
|
event.Signature,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to insert audit event: %w", err)
|
|
}
|
|
|
|
// Also log to zerolog for real-time visibility
|
|
logEvent := log.With().
|
|
Str("audit_id", event.ID).
|
|
Str("event", event.EventType).
|
|
Str("user", event.User).
|
|
Str("ip", event.IP).
|
|
Str("path", event.Path).
|
|
Time("timestamp", event.Timestamp).
|
|
Str("details", event.Details).
|
|
Logger()
|
|
|
|
if event.Success {
|
|
logEvent.Info().Msg("Audit event")
|
|
} else {
|
|
logEvent.Warn().Msg("Audit event - FAILED")
|
|
}
|
|
|
|
// Send to webhooks if configured
|
|
if l.webhookDelivery != nil {
|
|
l.webhookDelivery.Enqueue(event)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Query retrieves audit events matching the filter.
|
|
func (l *SQLiteLogger) Query(filter QueryFilter) ([]Event, error) {
|
|
l.mu.RLock()
|
|
defer l.mu.RUnlock()
|
|
|
|
query := "SELECT id, timestamp, event_type, user, ip, path, success, details, signature FROM audit_events WHERE 1=1"
|
|
args := []interface{}{}
|
|
|
|
if filter.ID != "" {
|
|
query += " AND id = ?"
|
|
args = append(args, filter.ID)
|
|
}
|
|
if filter.StartTime != nil {
|
|
query += " AND timestamp >= ?"
|
|
args = append(args, filter.StartTime.Unix())
|
|
}
|
|
if filter.EndTime != nil {
|
|
query += " AND timestamp <= ?"
|
|
args = append(args, filter.EndTime.Unix())
|
|
}
|
|
if filter.EventType != "" {
|
|
query += " AND event_type = ?"
|
|
args = append(args, filter.EventType)
|
|
}
|
|
if filter.User != "" {
|
|
query += " AND user = ?"
|
|
args = append(args, filter.User)
|
|
}
|
|
if filter.Success != nil {
|
|
success := 0
|
|
if *filter.Success {
|
|
success = 1
|
|
}
|
|
query += " AND success = ?"
|
|
args = append(args, success)
|
|
}
|
|
|
|
query += " ORDER BY timestamp DESC"
|
|
|
|
if filter.Limit > 0 {
|
|
query += " LIMIT ?"
|
|
args = append(args, filter.Limit)
|
|
}
|
|
if filter.Offset > 0 {
|
|
query += " OFFSET ?"
|
|
args = append(args, filter.Offset)
|
|
}
|
|
|
|
rows, err := l.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query audit events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []Event
|
|
for rows.Next() {
|
|
var e Event
|
|
var timestamp int64
|
|
var success int
|
|
var user, ip, path, details, signature sql.NullString
|
|
|
|
err := rows.Scan(&e.ID, ×tamp, &e.EventType, &user, &ip, &path, &success, &details, &signature)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan audit event: %w", err)
|
|
}
|
|
|
|
e.Timestamp = time.Unix(timestamp, 0)
|
|
e.Success = success == 1
|
|
e.User = user.String
|
|
e.IP = ip.String
|
|
e.Path = path.String
|
|
e.Details = details.String
|
|
e.Signature = signature.String
|
|
|
|
events = append(events, e)
|
|
}
|
|
|
|
return events, rows.Err()
|
|
}
|
|
|
|
// Count returns the number of events matching the filter.
|
|
func (l *SQLiteLogger) Count(filter QueryFilter) (int, error) {
|
|
l.mu.RLock()
|
|
defer l.mu.RUnlock()
|
|
|
|
query := "SELECT COUNT(*) FROM audit_events WHERE 1=1"
|
|
args := []interface{}{}
|
|
|
|
if filter.ID != "" {
|
|
query += " AND id = ?"
|
|
args = append(args, filter.ID)
|
|
}
|
|
if filter.StartTime != nil {
|
|
query += " AND timestamp >= ?"
|
|
args = append(args, filter.StartTime.Unix())
|
|
}
|
|
if filter.EndTime != nil {
|
|
query += " AND timestamp <= ?"
|
|
args = append(args, filter.EndTime.Unix())
|
|
}
|
|
if filter.EventType != "" {
|
|
query += " AND event_type = ?"
|
|
args = append(args, filter.EventType)
|
|
}
|
|
if filter.User != "" {
|
|
query += " AND user = ?"
|
|
args = append(args, filter.User)
|
|
}
|
|
if filter.Success != nil {
|
|
success := 0
|
|
if *filter.Success {
|
|
success = 1
|
|
}
|
|
query += " AND success = ?"
|
|
args = append(args, success)
|
|
}
|
|
|
|
var count int
|
|
err := l.db.QueryRow(query, args...).Scan(&count)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count audit events: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// GetWebhookURLs returns the configured webhook URLs.
|
|
func (l *SQLiteLogger) GetWebhookURLs() []string {
|
|
if l.webhookDelivery != nil {
|
|
return l.webhookDelivery.GetURLs()
|
|
}
|
|
return l.loadWebhookURLs()
|
|
}
|
|
|
|
// UpdateWebhookURLs updates the webhook configuration.
|
|
func (l *SQLiteLogger) UpdateWebhookURLs(urls []string) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// Save to config table
|
|
value := strings.Join(urls, ",")
|
|
_, err := l.db.Exec(`
|
|
INSERT INTO audit_config (key, value, updated_at) VALUES ('webhook_urls', ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
value, time.Now().Unix())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save webhook URLs: %w", err)
|
|
}
|
|
|
|
// Update delivery worker
|
|
if len(urls) > 0 {
|
|
if l.webhookDelivery == nil {
|
|
l.webhookDelivery = NewWebhookDelivery(urls)
|
|
l.webhookDelivery.Start()
|
|
} else {
|
|
l.webhookDelivery.UpdateURLs(urls)
|
|
}
|
|
} else if l.webhookDelivery != nil {
|
|
l.webhookDelivery.Stop()
|
|
l.webhookDelivery = nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifySignature checks if an event's signature is valid.
|
|
func (l *SQLiteLogger) VerifySignature(event Event) bool {
|
|
return l.signer.Verify(event)
|
|
}
|
|
|
|
// Close gracefully shuts down the logger.
|
|
func (l *SQLiteLogger) Close() error {
|
|
l.closeOnce.Do(func() {
|
|
close(l.stopChan)
|
|
|
|
if l.webhookDelivery != nil {
|
|
l.webhookDelivery.Stop()
|
|
}
|
|
|
|
l.wg.Wait()
|
|
|
|
if err := l.db.Close(); err != nil {
|
|
l.closeErr = fmt.Errorf("failed to close audit database: %w", err)
|
|
return
|
|
}
|
|
|
|
log.Info().Msg("SQLite audit logger closed")
|
|
})
|
|
|
|
return l.closeErr
|
|
}
|
|
|
|
// loadWebhookURLs loads webhook URLs from the config table.
|
|
func (l *SQLiteLogger) loadWebhookURLs() []string {
|
|
var value string
|
|
err := l.db.QueryRow(`SELECT value FROM audit_config WHERE key = 'webhook_urls'`).Scan(&value)
|
|
if err != nil || value == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(value, ",")
|
|
}
|
|
|
|
// retentionWorker runs periodically to clean up old events.
|
|
func (l *SQLiteLogger) retentionWorker() {
|
|
defer l.wg.Done()
|
|
|
|
// Run at 3 AM daily
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
// Also run once at startup after a short delay
|
|
time.AfterFunc(5*time.Minute, func() {
|
|
l.cleanupOldEvents()
|
|
})
|
|
|
|
for {
|
|
select {
|
|
case <-l.stopChan:
|
|
return
|
|
case <-ticker.C:
|
|
l.cleanupOldEvents()
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanupOldEvents deletes events older than the retention period.
|
|
func (l *SQLiteLogger) cleanupOldEvents() {
|
|
if l.retentionDays <= 0 {
|
|
return
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -l.retentionDays).Unix()
|
|
|
|
result, err := l.db.Exec(`DELETE FROM audit_events WHERE timestamp < ?`, cutoff)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to cleanup old audit events")
|
|
return
|
|
}
|
|
|
|
deleted, _ := result.RowsAffected()
|
|
if deleted > 0 {
|
|
log.Info().
|
|
Int64("deleted", deleted).
|
|
Int("retentionDays", l.retentionDays).
|
|
Msg("Cleaned up old audit events")
|
|
|
|
// Log the cleanup as an audit event (without recursion - direct insert)
|
|
_, _ = l.db.Exec(`
|
|
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, 'audit_cleanup', 'system', '', '', 1, ?, '')`,
|
|
fmt.Sprintf("cleanup-%d", time.Now().Unix()),
|
|
time.Now().Unix(),
|
|
fmt.Sprintf("Deleted %d events older than %d days", deleted, l.retentionDays),
|
|
)
|
|
}
|
|
}
|
|
|
|
// GetRetentionDays returns the current retention period.
|
|
func (l *SQLiteLogger) GetRetentionDays() int {
|
|
return l.retentionDays
|
|
}
|
|
|
|
// SetRetentionDays updates the retention period.
|
|
func (l *SQLiteLogger) SetRetentionDays(days int) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.retentionDays = days
|
|
|
|
// Save to config
|
|
_, _ = l.db.Exec(`
|
|
INSERT INTO audit_config (key, value, updated_at) VALUES ('retention_days', ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
fmt.Sprintf("%d", days), time.Now().Unix())
|
|
}
|