audit

package module
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

audit-kit

Go Reference Go Report Card License codecov

中文文档

A unified audit logging toolkit for Go services. It provides a single storage interface over several backends (file, database, Redis), an async writer with a worker pool and a bounded queue, a fluent record builder, and masking for sensitive fields.

Features

  • Storage interface: one interface (Write/Query/Close) for every backend
  • Multiple backends: file (JSON Lines), database (PostgreSQL/MySQL/SQLite), Redis, no-op
  • Async writing: worker pool with a bounded queue, so logging never blocks a request
  • Durable shutdown: Stop() drains the queue and writes it before the context is cancelled
  • Multi-storage: fan one record out to several backends at once
  • Data masking: email, phone, IP and free-form string masking
  • Fluent API: builder pattern for records, functional options for the convenience helpers
  • Query support: filter and paginate stored records
  • Extensible: custom event types and arbitrary metadata

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0)
  • Optional: github.com/redis/go-redis/v9 for Redis storage
  • Optional: github.com/go-sql-driver/mysql, github.com/lib/pq or modernc.org/sqlite for database storage

Installation

go get github.com/soulteary/audit-kit

Quick Start

package main

import (
    "context"
    "log"

    audit "github.com/soulteary/audit-kit"
)

func main() {
    // filePath must come from trusted config, not from user input.
    storage, err := audit.NewFileStorage("/var/log/audit.log")
    if err != nil {
        log.Fatal(err)
    }

    logger := audit.NewLogger(storage, nil)
    defer logger.Stop()

    record := audit.NewRecord(audit.EventLoginSuccess, audit.ResultSuccess).
        WithUserID("user123").
        WithIP("192.168.1.1").
        WithUserAgent("Mozilla/5.0")

    logger.Log(context.Background(), record)
}

Usage

NewLoggerWithWriter puts a bounded queue and a worker pool in front of the storage, so Log returns without waiting for the backend.

config := audit.DefaultConfig()
config.Writer = &audit.WriterConfig{
    QueueSize:   1000,
    Workers:     4,
    StopTimeout: 10 * time.Second,
}

logger := audit.NewLoggerWithWriter(storage, config)
defer logger.Stop() // drains the queue, then closes storage

logger.Log(ctx, record) // non-blocking

Log is best-effort by design: when the queue is full the record is dropped rather than blocking the caller. Set a callback so a drop is never silent:

config := audit.DefaultConfig()
config.Writer = audit.DefaultWriterConfig()
config.OnEnqueueFailed = func(r *audit.Record) {
    metrics.AuditDropped.Inc()      // or write to a fallback sink
}
config.OnWriteFailed = func(r *audit.Record, err error) {
    log.Printf("audit write failed: %v", err)
}

Both callbacks are caller code invoked outside the writer's lifecycle lock, so they may safely block, or even call Stop(), without deadlocking shutdown. They may also be set after the workers have started.

Shutdown and queue statistics
logger := audit.NewLoggerWithWriter(storage, nil)

// ... later, on SIGTERM:
if err := logger.Stop(); err != nil {
    log.Printf("audit shutdown: %v", err)
}

// Inspect the queue at any time.
stats := logger.GetStats() // *audit.Stats, nil when the logger is synchronous
if stats != nil {
    log.Printf("queued=%d/%d workers=%d started=%t stopped=%t",
        stats.QueueLength, stats.QueueCap, stats.Workers, stats.Started, stats.Stopped)
}

Stop() marks the writer stopped, waits for in-flight Enqueue calls, drains everything still queued with a live context, and only then cancels the context and closes the storage. If the drain exceeds StopTimeout the writer logs how many records were left unwritten. Calling Log after Stop() is safe and simply drops the record.

Database storage
// PostgreSQL
storage, err := audit.NewDatabaseStorage("postgres://user:pass@localhost/db")

// MySQL
storage, err := audit.NewDatabaseStorage("mysql://user:pass@tcp(localhost:3306)/db")

// An existing *sql.DB (handy for tests)
db, _ := sql.Open("sqlite", ":memory:")
storage, err := audit.NewDatabaseStorageFromDB(db, "sqlite", nil)

// Custom table name — ASCII letters, digits and underscore only, max 64 chars
storage, err := audit.NewDatabaseStorageWithConfig(dsn, &audit.DatabaseConfig{
    TableName: "audit_records",
})
Redis storage
import "github.com/redis/go-redis/v9"

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

storage := audit.NewRedisStorageWithConfig(client, &audit.RedisConfig{
    KeyPrefix: "myapp:audit:",
    TTL:       7 * 24 * time.Hour,
})

// The index key itself has no TTL; prune expired references periodically.
removed, err := storage.Cleanup(ctx)
Multi-storage
fileStorage, _ := audit.NewFileStorage("/var/log/audit.log")
redisStorage := audit.NewRedisStorage(redisClient)

multi := audit.NewMultiStorage(fileStorage, redisStorage)
logger := audit.NewLogger(multi, nil)
Building storage from configuration
storage, err := audit.NewStorageFromType(
    audit.ParseStorageType(os.Getenv("AUDIT_STORAGE")), // "file" | "database" | "redis" | "none"
    &audit.StorageOptions{
        FilePath:    "/var/log/audit.log",
        DatabaseURL: os.Getenv("DATABASE_URL"),
        RedisClient: redisClient,
        RedisPrefix: "myapp:audit:",
        RedisTTL:    7 * 24 * time.Hour,
        TableName:   "audit_records",
    },
)

audit.NewNoopStorage() discards everything, which is useful in tests and when auditing is switched off.

Querying records
filter := audit.DefaultQueryFilter().
    WithEventType("login_success").
    WithUserID("user123").
    WithTimeRange(startUnix, endUnix).
    WithLimit(50).
    WithOffset(0)

records, err := logger.Query(ctx, filter)
Convenience logging helpers
// OTP / verification challenges
logger.LogChallenge(ctx, audit.EventChallengeCreated, "ch_123", "user123", audit.ResultSuccess,
    audit.WithRecordChannel("email"),
    audit.WithRecordDestination("test@example.com"),
)

// Authentication
logger.LogAuth(ctx, audit.EventLoginSuccess, "user123", audit.ResultSuccess,
    audit.WithRecordIP("192.168.1.1"),
    audit.WithRecordUserAgent("Mozilla/5.0"),
)

// Access control
logger.LogAccess(ctx, audit.EventAccessGranted, "user123", "/api/users", audit.ResultSuccess)
Custom event types
const (
    EventPasswordChange audit.EventType = "password_change"
    EventAPIKeyCreated  audit.EventType = "api_key_created"
)

record := audit.NewRecord(EventPasswordChange, audit.ResultSuccess).
    WithUserID("user123").
    WithMetadata("changed_by", "admin")
Data masking
// Applied automatically to Destination when Config.MaskDestination is true.
config := audit.DefaultConfig()
config.MaskDestination = true // default

// Or call the helpers directly.
audit.MaskEmail("user@example.com") // u***@example.com
audit.MaskPhone("13800138000")      // 138****8000
audit.MaskIP("192.168.1.100")       // 192.***.100
audit.MaskString("secret-token", 2) // se********en
audit.MaskDestination(dest, "sms")  // picks the right masker for the channel

MaskIP keeps the first and last IPv4 octet. That is pseudonymisation, not anonymisation — it narrows an address to one of at most 65536, and often far fewer. Use MaskString when you need a stronger reduction. IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) are normalised to their IPv4 form first, so they mask to 192.***.1 rather than leaking three octets.

Serialising records
data, err := record.ToJSON()
back, err := audit.RecordFromJSON(data)
clone := record.Copy() // deep copy, including Metadata

A record whose JSON exceeds audit.MaxRecordJSONSize (1 MiB) is rejected, which keeps one oversized metadata blob from filling the log. Note that after a JSON round-trip numeric metadata values come back as float64.

Log callback
logger.SetLogCallback(func(record *audit.Record) {
    log.Printf("[AUDIT] %s user=%s result=%s",
        record.EventType, record.UserID, record.Result)
})

Configuration

config := &audit.Config{
    Enabled:         true,               // false disables logging entirely
    MaskDestination: true,               // mask phone/email in Destination
    TTL:             7 * 24 * time.Hour, // Redis storage TTL
    Writer: &audit.WriterConfig{
        QueueSize:   1000,               // bounded async queue
        Workers:     2,                  // worker goroutines
        StopTimeout: 10 * time.Second,   // bound on the shutdown drain
    },
    OnEnqueueFailed: func(r *audit.Record) { /* queue full */ },
    OnWriteFailed:   func(r *audit.Record, err error) { /* backend rejected it */ },
}
Option Default Notes
Enabled true false makes Log a no-op
MaskDestination true masks Record.Destination by channel
TTL 168h (7 days) Redis only
Writer.QueueSize 1000 a non-positive value falls back to the default
Writer.Workers 2 a non-positive value falls back to the default
Writer.StopTimeout 10s a non-positive value falls back to the default
OnEnqueueFailed nil without it, a dropped record is only logged
OnWriteFailed nil without it, a failed write is only logged

API Reference

Records
Function Description
NewRecord(eventType, result) Start a record
(*Record).With… Fluent setters (WithUserID, WithIP, WithMetadata, …)
(*Record).Copy() Deep copy
(*Record).ToJSON() / RecordFromJSON(data) Serialise / parse
Loggers and writers
Function Description
NewLogger(storage, config) Synchronous logger
NewLoggerWithWriter(storage, config) Logger backed by the async writer
(*Logger).Log/LogAuth/LogAccess/LogChallenge Write a record
(*Logger).Query(ctx, filter) Query stored records
(*Logger).GetStats() Queue statistics, nil when synchronous
(*Logger).Stop() Drain, then close storage
NewWriter(storage, config) Use the writer on its own
(*Writer).Start/Enqueue/Stop/GetStats Writer lifecycle
Storage
Function Description
NewFileStorage(path) JSON Lines file; Rotate() rotates it
NewDatabaseStorage(url) PostgreSQL / MySQL from a DSN
NewDatabaseStorageFromDB(db, dbType, cfg) Wrap an existing *sql.DB
NewRedisStorage(client) Redis; Cleanup(ctx) prunes the index
NewMultiStorage(storages…) Fan-out to several backends
NewNoopStorage() Discard everything
NewStorageFromType(type, opts) Build from configuration
Masking
Function Description
MaskEmail(email) u***@example.com
MaskPhone(phone) 138****8000
MaskIP(ip) 192.***.100 (pseudonymisation)
MaskString(s, keepChars) Keep first/last keepChars; negative is treated as 0
MaskDestination(dest, channel) Channel-aware masking

Event Types

Category Event Type Description
Challenge challenge_created OTP challenge created
Challenge challenge_verified OTP verification successful
Challenge challenge_revoked Challenge manually revoked
Challenge challenge_expired Challenge expired
Send send_success Message sent successfully
Send send_failed Message send failed
Verification verification_success Verification successful
Verification verification_failed Verification failed
Authentication login_success Login successful
Authentication login_failed Login failed
Authentication logout User logged out
Session session_create Session created
Session session_expire Session expired
Authorization access_granted Access granted
Authorization access_denied Access denied
User user_created User created
User user_updated User updated
User user_deleted User deleted
User user_locked User account locked
User user_unlocked User account unlocked
Rate Limit rate_limited Rate limit triggered
Custom custom Custom event

Results are audit.ResultSuccess, audit.ResultFailure and audit.ResultPending.

Upgrade Notes (v1.9.0)

Dependency refresh only. No API was removed and no call needs rewriting.

  • Test Redis is miniredis v2.39.0 (was v2.36.1).
  • The SQLite driver is modernc.org/sqlite v1.58.0 (was v1.44.3).

Upgrade Notes (v1.8.0)

This release fixes two lifecycle defects in the async writer. No API was removed, and no call needs rewriting — but the observable behaviour changes.

  • Queued records now survive shutdown. Stop() used to cancel the writer's context before draining the queue, so every record still queued was written with a dead context and rejected by the backend: a 100-record queue persisted nothing. The context is now cancelled only after the workers finish, so StopTimeout bounds a drain that actually writes. If you previously sized StopTimeout around a drain that never succeeded, give it enough room for the real thing.
  • Log/Enqueue after Stop() no longer panics. The queue used to be closed while a send could still be in flight, which panicked with "send on closed channel" under the right interleaving. The queue is never closed now; a post-Stop call returns without sending.
  • A slow or re-entrant queue-full callback no longer hangs shutdown. OnEnqueueFailed is invoked outside the lifecycle lock, so a callback that blocks — or calls Stop() itself — no longer deadlocks or starves StopTimeout.
  • OnEnqueueFailed / OnWriteFailed can be set while workers run. Both fields were previously written without synchronisation, which raced with the workers reading them.
  • GetStats().QueueLength reports the real depth after Stop(). It used to be forced to 0 once stopped, which hid records lost to a timeout.
  • MaskIP handles IPv4-mapped IPv6. ::ffff:192.168.1.1 masked to ::ffff:192.***.1 before, exposing more than intended; it now yields 192.***.1. If you match on masked output, re-check those assertions.
  • MaskString with a negative keepChars returns a fully masked string instead of panicking with a slice-bounds error.
  • Table names are validated as ASCII [a-zA-Z0-9_]. validateTableName documented that set but used unicode.IsLetter/IsNumber, so Cyrillic letters and full-width digits were accepted and produced identifiers several engines need quoted. A non-ASCII DatabaseConfig.TableName is now rejected at construction.

Security and Operational Notes

  • File storage: pass only trusted paths to NewFileStorage. A user-controlled path allows traversal, and a symlink can redirect the log.
  • Database errors: do not log err.Error() verbatim from database storage — drivers may include the DSN, and therefore the password. Log a fixed message or check the error type.
  • Dropped records: a full queue drops records by design. Set OnEnqueueFailed and size QueueSize for your peak, or use the synchronous logger where no record may ever be lost.
  • Redis: set EventID or ChallengeID so keys stay unique, and run Cleanup() periodically — the index key itself carries no TTL.
  • Metadata: after a JSON round-trip, numbers are float64. Type-assert accordingly.

Project Structure

audit-kit/
├── types.go     # Record, event/result constants, record options
├── storage.go   # Storage interface and QueryFilter
├── logger.go    # Logger, Config, DefaultConfig
├── writer.go    # Async writer, worker pool, lifecycle
├── file.go      # File storage (JSON Lines)
├── database.go  # Database storage (PostgreSQL/MySQL/SQLite)
├── redis.go     # Redis storage
├── factory.go   # Storage factory and multi-storage
└── mask.go      # Masking helpers

Testing

go test ./...

# With coverage
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html

A few tests simulate I/O failures with chmod, which uid 0 ignores; those skip when the suite runs as root.

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

Apache License 2.0 — see LICENSE for details.

Documentation

Overview

Package audit provides a unified audit logging toolkit for Go services. It supports multiple storage backends (file, database, Redis) and async writing.

Index

Constants

View Source
const MaxRecordJSONSize = 1 << 20

MaxRecordJSONSize is the maximum allowed size for RecordFromJSON input (1MB). Larger payloads are rejected to avoid DoS from unbounded unmarshaling.

Variables

This section is empty.

Functions

func MaskDestination

func MaskDestination(dest string, channel string) string

MaskDestination masks a destination (phone or email) based on channel

func MaskEmail

func MaskEmail(email string) string

MaskEmail masks an email address

func MaskIP

func MaskIP(ip string) string

MaskIP masks an IP address, keeping the first and last IPv4 octet (192.168.1.5 -> 192.***.5).

Note this is pseudonymisation, not anonymisation: with the first and last octet retained an address is narrowed to 1 of 65536, and often far fewer in practice. Use MaskString if a stronger reduction is required.

func MaskPhone

func MaskPhone(phone string) string

MaskPhone masks a phone number

func MaskString

func MaskString(s string, keepChars int) string

MaskString masks a string, keeping first and last n characters. A negative keepChars is treated as 0.

Types

type Config

type Config struct {
	// Enabled controls whether audit logging is enabled
	Enabled bool

	// MaskDestination controls whether destinations (phone/email) should be masked
	MaskDestination bool

	// TTL for Redis/cache storage (0 means use storage default)
	TTL time.Duration

	// Writer configuration (for async writing)
	Writer *WriterConfig

	// OnEnqueueFailed, if set, is invoked when an async record is dropped
	// because the writer queue is full. Callers can use it to increment a
	// drop-counter metric or emit an alert so silent audit loss is observable.
	OnEnqueueFailed func(record *Record)

	// OnWriteFailed, if set, is invoked when a record fails to persist to the
	// backing storage.
	OnWriteFailed func(record *Record, err error)
}

Config holds configuration for the audit logger

func DefaultConfig

func DefaultConfig() *Config

DefaultConfig returns default audit configuration

type DatabaseConfig

type DatabaseConfig struct {
	TableName string // Custom table name (default: "audit_logs")
}

DatabaseConfig holds configuration for database storage

func DefaultDatabaseConfig

func DefaultDatabaseConfig() *DatabaseConfig

DefaultDatabaseConfig returns default database configuration

type DatabaseStorage

type DatabaseStorage struct {
	// contains filtered or unexported fields
}

DatabaseStorage implements Storage interface for database-based audit logging Supports PostgreSQL and MySQL

func NewDatabaseStorage

func NewDatabaseStorage(databaseURL string) (*DatabaseStorage, error)

NewDatabaseStorage creates a new database storage instance

func NewDatabaseStorageFromDB

func NewDatabaseStorageFromDB(db *sql.DB, dbType string, config *DatabaseConfig) (*DatabaseStorage, error)

NewDatabaseStorageFromDB creates a new database storage from existing *sql.DB

func NewDatabaseStorageWithConfig

func NewDatabaseStorageWithConfig(databaseURL string, config *DatabaseConfig) (*DatabaseStorage, error)

NewDatabaseStorageWithConfig creates a new database storage instance with config

func (*DatabaseStorage) Close

func (s *DatabaseStorage) Close() error

Close closes the database connection

func (*DatabaseStorage) DB

func (s *DatabaseStorage) DB() *sql.DB

DB returns the underlying database connection

func (*DatabaseStorage) DBType

func (s *DatabaseStorage) DBType() string

DBType returns the database type

func (*DatabaseStorage) Query

func (s *DatabaseStorage) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query queries audit records from the database

func (*DatabaseStorage) Write

func (s *DatabaseStorage) Write(ctx context.Context, record *Record) error

Write writes an audit record to the database

type EventType

type EventType string

EventType represents the type of audit event

const (
	// Challenge lifecycle events
	EventChallengeCreated  EventType = "challenge_created"
	EventChallengeVerified EventType = "challenge_verified"
	EventChallengeRevoked  EventType = "challenge_revoked"
	EventChallengeExpired  EventType = "challenge_expired"

	// Send events
	EventSendSuccess EventType = "send_success"
	EventSendFailed  EventType = "send_failed"

	// Verification events
	EventVerificationSuccess EventType = "verification_success"
	EventVerificationFailed  EventType = "verification_failed"

	// Authentication events
	EventLoginSuccess  EventType = "login_success"
	EventLoginFailed   EventType = "login_failed"
	EventLogout        EventType = "logout"
	EventSessionCreate EventType = "session_create"
	EventSessionExpire EventType = "session_expire"

	// Authorization events
	EventAccessGranted EventType = "access_granted"
	EventAccessDenied  EventType = "access_denied"

	// User management events
	EventUserCreated  EventType = "user_created"
	EventUserUpdated  EventType = "user_updated"
	EventUserDeleted  EventType = "user_deleted"
	EventUserLocked   EventType = "user_locked"
	EventUserUnlocked EventType = "user_unlocked"

	// Rate limit events
	EventRateLimited EventType = "rate_limited"

	// Generic events
	EventCustom EventType = "custom"
)

Common event types for OTP/Authentication services

type FileStorage

type FileStorage struct {
	// contains filtered or unexported fields
}

FileStorage implements Storage interface for file-based audit logging Uses JSON Lines format (one JSON object per line)

func NewFileStorage

func NewFileStorage(filePath string) (*FileStorage, error)

NewFileStorage creates a new file storage instance. filePath must come from trusted configuration only; do not pass user-controlled paths (path traversal or symlinks could write audit logs to unintended locations).

func (*FileStorage) Close

func (s *FileStorage) Close() error

Close closes the file and releases resources

func (*FileStorage) FilePath

func (s *FileStorage) FilePath() string

FilePath returns the file path

func (*FileStorage) Query

func (s *FileStorage) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query reads audit records from the file matching the filter Note: File storage query is simple and may be slow for large files For production use, consider using database storage

func (*FileStorage) Rotate

func (s *FileStorage) Rotate() error

Rotate rotates the audit log file Creates a new file with timestamp suffix and reopens the main file

func (*FileStorage) Write

func (s *FileStorage) Write(ctx context.Context, record *Record) error

Write writes an audit record to the file (JSON Lines format)

type Logger

type Logger struct {
	// contains filtered or unexported fields
}

Logger handles audit logging with optional async writing

func NewLogger

func NewLogger(storage Storage, config *Config) *Logger

NewLogger creates a new audit logger with storage

func NewLoggerWithWriter

func NewLoggerWithWriter(storage Storage, config *Config) *Logger

NewLoggerWithWriter creates a new audit logger with async writer

func (*Logger) GetStats

func (l *Logger) GetStats() *Stats

GetStats returns writer statistics (if async writer is used)

func (*Logger) Log

func (l *Logger) Log(ctx context.Context, record *Record)

Log records an audit event. The provided record is never modified; a copy is made for masking and writing, so the caller may safely reuse the record.

func (*Logger) LogAccess

func (l *Logger) LogAccess(ctx context.Context, eventType EventType, userID, resource string, result Result, opts ...RecordOption)

LogAccess logs an access control event

func (*Logger) LogAuth

func (l *Logger) LogAuth(ctx context.Context, eventType EventType, userID string, result Result, opts ...RecordOption)

LogAuth logs an authentication event

func (*Logger) LogChallenge

func (l *Logger) LogChallenge(ctx context.Context, eventType EventType, challengeID, userID string, result Result, opts ...RecordOption)

LogChallenge logs a challenge-related event

func (*Logger) Query

func (l *Logger) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query queries audit records from storage. Filter is normalized (limit/offset) before being passed to storage so behavior is consistent across backends.

func (*Logger) SetLogCallback

func (l *Logger) SetLogCallback(fn func(record *Record))

SetLogCallback sets a callback that is called for each log entry Useful for also logging to standard logger

func (*Logger) Stop

func (l *Logger) Stop() error

Stop stops the logger and releases resources

type MultiStorage

type MultiStorage struct {
	// contains filtered or unexported fields
}

MultiStorage combines multiple storage backends

func NewMultiStorage

func NewMultiStorage(storages ...Storage) *MultiStorage

NewMultiStorage creates a storage that writes to multiple backends

func (*MultiStorage) Close

func (m *MultiStorage) Close() error

Close closes all storage backends

func (*MultiStorage) Query

func (m *MultiStorage) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query queries from the first storage backend

func (*MultiStorage) Storages

func (m *MultiStorage) Storages() []Storage

Storages returns all storage backends

func (*MultiStorage) Write

func (m *MultiStorage) Write(ctx context.Context, record *Record) error

Write writes to all storage backends

type NoopStorage

type NoopStorage struct{}

NoopStorage is a no-op storage that discards all records

func NewNoopStorage

func NewNoopStorage() *NoopStorage

NewNoopStorage creates a new no-op storage

func (*NoopStorage) Close

func (s *NoopStorage) Close() error

Close does nothing

func (*NoopStorage) Query

func (s *NoopStorage) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query returns empty results

func (*NoopStorage) Write

func (s *NoopStorage) Write(ctx context.Context, record *Record) error

Write does nothing

type QueryFilter

type QueryFilter struct {
	// Filter by event type
	EventType string `json:"event_type,omitempty"`

	// Filter by subject
	UserID      string `json:"user_id,omitempty"`
	ChallengeID string `json:"challenge_id,omitempty"`
	SessionID   string `json:"session_id,omitempty"`

	// Filter by channel and result
	Channel string `json:"channel,omitempty"`
	Result  string `json:"result,omitempty"`

	// Time range filters (Unix timestamps)
	StartTime int64 `json:"start_time,omitempty"`
	EndTime   int64 `json:"end_time,omitempty"`

	// Filter by IP
	IP string `json:"ip,omitempty"`

	// Pagination
	Limit  int `json:"limit,omitempty"`  // Maximum number of records (default: 100)
	Offset int `json:"offset,omitempty"` // Offset for pagination (default: 0)
}

QueryFilter defines filter criteria for querying audit records

func DefaultQueryFilter

func DefaultQueryFilter() *QueryFilter

DefaultQueryFilter returns a default query filter with sensible defaults

func (*QueryFilter) Normalize

func (f *QueryFilter) Normalize()

Normalize ensures filter has valid values

func (*QueryFilter) WithChallengeID

func (f *QueryFilter) WithChallengeID(challengeID string) *QueryFilter

WithChallengeID sets the challenge ID filter

func (*QueryFilter) WithChannel

func (f *QueryFilter) WithChannel(channel string) *QueryFilter

WithChannel sets the channel filter

func (*QueryFilter) WithEventType

func (f *QueryFilter) WithEventType(eventType string) *QueryFilter

WithEventType sets the event type filter

func (*QueryFilter) WithIP

func (f *QueryFilter) WithIP(ip string) *QueryFilter

WithIP sets the IP filter

func (*QueryFilter) WithLimit

func (f *QueryFilter) WithLimit(limit int) *QueryFilter

WithLimit sets the limit

func (*QueryFilter) WithOffset

func (f *QueryFilter) WithOffset(offset int) *QueryFilter

WithOffset sets the offset

func (*QueryFilter) WithResult

func (f *QueryFilter) WithResult(result string) *QueryFilter

WithResult sets the result filter

func (*QueryFilter) WithSessionID

func (f *QueryFilter) WithSessionID(sessionID string) *QueryFilter

WithSessionID sets the session ID filter

func (*QueryFilter) WithTimeRange

func (f *QueryFilter) WithTimeRange(startTime, endTime int64) *QueryFilter

WithTimeRange sets the time range filter

func (*QueryFilter) WithUserID

func (f *QueryFilter) WithUserID(userID string) *QueryFilter

WithUserID sets the user ID filter

type Record

type Record struct {
	// Event identification
	EventType EventType `json:"event_type"`
	EventID   string    `json:"event_id,omitempty"` // Unique event identifier

	// Subject identification
	UserID      string `json:"user_id,omitempty"`
	ChallengeID string `json:"challenge_id,omitempty"`
	SessionID   string `json:"session_id,omitempty"`

	// Event details
	Channel     string `json:"channel,omitempty"`     // sms, email, push, etc.
	Destination string `json:"destination,omitempty"` // May be masked
	Purpose     string `json:"purpose,omitempty"`     // login, reset, bind, etc.
	Resource    string `json:"resource,omitempty"`    // Accessed resource

	// Result
	Result Result `json:"result"`
	Reason string `json:"reason,omitempty"` // Failure reason

	// Provider info (for external services)
	Provider          string `json:"provider,omitempty"`
	ProviderMessageID string `json:"provider_message_id,omitempty"`

	// Request context
	IP        string `json:"ip,omitempty"`
	UserAgent string `json:"user_agent,omitempty"`
	RequestID string `json:"request_id,omitempty"`
	TraceID   string `json:"trace_id,omitempty"`

	// Timing
	Timestamp  int64 `json:"timestamp"`             // Unix timestamp
	DurationMS int64 `json:"duration_ms,omitempty"` // Operation duration

	// Extensible metadata
	Metadata map[string]interface{} `json:"metadata,omitempty"`
}

Record represents an audit log entry

func NewRecord

func NewRecord(eventType EventType, result Result) *Record

NewRecord creates a new audit record with required fields

func RecordFromJSON

func RecordFromJSON(data []byte) (*Record, error)

RecordFromJSON deserializes a record from JSON. Input larger than MaxRecordJSONSize is rejected to prevent memory exhaustion. Metadata is unmarshaled as map[string]interface{} without depth limit; do not pass untrusted JSON with deeply nested metadata.

func (*Record) Copy added in v1.1.0

func (r *Record) Copy() *Record

Copy returns a shallow copy of the record. The caller's record is unchanged. Used internally so Log() can mask and write without mutating the original.

func (*Record) SetTimestamp

func (r *Record) SetTimestamp(ts int64) *Record

SetTimestamp sets the timestamp (useful for testing)

func (*Record) ToJSON

func (r *Record) ToJSON() ([]byte, error)

ToJSON serializes the record to JSON

func (*Record) WithChallengeID

func (r *Record) WithChallengeID(challengeID string) *Record

WithChallengeID sets the challenge ID

func (*Record) WithChannel

func (r *Record) WithChannel(channel string) *Record

WithChannel sets the channel (sms, email, etc.)

func (*Record) WithDestination

func (r *Record) WithDestination(destination string) *Record

WithDestination sets the destination (phone, email, etc.)

func (*Record) WithDuration

func (r *Record) WithDuration(durationMS int64) *Record

WithDuration sets the operation duration in milliseconds

func (*Record) WithIP

func (r *Record) WithIP(ip string) *Record

WithIP sets the client IP

func (*Record) WithMetadata

func (r *Record) WithMetadata(key string, value interface{}) *Record

WithMetadata sets custom metadata

func (*Record) WithProvider

func (r *Record) WithProvider(provider, messageID string) *Record

WithProvider sets the provider info

func (*Record) WithPurpose

func (r *Record) WithPurpose(purpose string) *Record

WithPurpose sets the purpose (login, reset, etc.)

func (*Record) WithReason

func (r *Record) WithReason(reason string) *Record

WithReason sets the failure reason

func (*Record) WithRequestID

func (r *Record) WithRequestID(requestID string) *Record

WithRequestID sets the request ID

func (*Record) WithResource

func (r *Record) WithResource(resource string) *Record

WithResource sets the accessed resource

func (*Record) WithSessionID

func (r *Record) WithSessionID(sessionID string) *Record

WithSessionID sets the session ID

func (*Record) WithTraceID

func (r *Record) WithTraceID(traceID string) *Record

WithTraceID sets the trace ID

func (*Record) WithUserAgent

func (r *Record) WithUserAgent(ua string) *Record

WithUserAgent sets the user agent

func (*Record) WithUserID

func (r *Record) WithUserID(userID string) *Record

WithUserID sets the user ID

type RecordOption

type RecordOption func(*Record)

RecordOption is a function that modifies a record

func WithRecordChannel

func WithRecordChannel(channel string) RecordOption

WithRecordChannel returns an option that sets the channel

func WithRecordDestination

func WithRecordDestination(dest string) RecordOption

WithRecordDestination returns an option that sets the destination

func WithRecordIP

func WithRecordIP(ip string) RecordOption

WithRecordIP returns an option that sets the IP

func WithRecordMetadata

func WithRecordMetadata(key string, value interface{}) RecordOption

WithRecordMetadata returns an option that sets metadata

func WithRecordProvider

func WithRecordProvider(provider, messageID string) RecordOption

WithRecordProvider returns an option that sets the provider

func WithRecordPurpose

func WithRecordPurpose(purpose string) RecordOption

WithRecordPurpose returns an option that sets the purpose

func WithRecordReason

func WithRecordReason(reason string) RecordOption

WithRecordReason returns an option that sets the reason

func WithRecordRequestID

func WithRecordRequestID(requestID string) RecordOption

WithRecordRequestID returns an option that sets the request ID

func WithRecordTraceID

func WithRecordTraceID(traceID string) RecordOption

WithRecordTraceID returns an option that sets the trace ID

func WithRecordUserAgent

func WithRecordUserAgent(ua string) RecordOption

WithRecordUserAgent returns an option that sets the user agent

type RedisConfig

type RedisConfig struct {
	KeyPrefix string        // Key prefix (default: "audit:")
	TTL       time.Duration // Time-to-live for records (default: 7 days)
}

RedisConfig holds configuration for Redis storage

func DefaultRedisConfig

func DefaultRedisConfig() *RedisConfig

DefaultRedisConfig returns default Redis configuration

type RedisStorage

type RedisStorage struct {
	// contains filtered or unexported fields
}

RedisStorage implements Storage interface for Redis-based audit logging Suitable for short-term storage and quick access

func NewRedisStorage

func NewRedisStorage(client *redis.Client) *RedisStorage

NewRedisStorage creates a new Redis storage instance

func NewRedisStorageWithConfig

func NewRedisStorageWithConfig(client *redis.Client, config *RedisConfig) *RedisStorage

NewRedisStorageWithConfig creates a new Redis storage instance with config

func (*RedisStorage) Cleanup

func (s *RedisStorage) Cleanup(ctx context.Context) (int64, error)

Cleanup removes expired keys from the index

func (*RedisStorage) Client

func (s *RedisStorage) Client() *redis.Client

Client returns the underlying Redis client

func (*RedisStorage) Close

func (s *RedisStorage) Close() error

Close closes the Redis connection

func (*RedisStorage) KeyPrefix

func (s *RedisStorage) KeyPrefix() string

KeyPrefix returns the key prefix

func (*RedisStorage) Query

func (s *RedisStorage) Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

Query queries audit records from Redis

func (*RedisStorage) TTL

func (s *RedisStorage) TTL() time.Duration

TTL returns the TTL

func (*RedisStorage) Write

func (s *RedisStorage) Write(ctx context.Context, record *Record) error

Write writes an audit record to Redis

type Result

type Result string

Result represents the outcome of an audit event

const (
	ResultSuccess Result = "success"
	ResultFailure Result = "failure"
	ResultPending Result = "pending"
)

type Stats

type Stats struct {
	QueueLength int
	QueueCap    int
	Workers     int
	Started     bool
	Stopped     bool
}

Stats returns writer statistics

type Storage

type Storage interface {
	// Write writes an audit record to the storage backend
	Write(ctx context.Context, record *Record) error

	// Query queries audit records based on filter criteria
	// Returns records matching the filter, ordered by timestamp (newest first)
	Query(ctx context.Context, filter *QueryFilter) ([]*Record, error)

	// Close closes the storage connection and releases resources
	Close() error
}

Storage defines the interface for audit log storage backends

func NewStorageFromType

func NewStorageFromType(storageType StorageType, opts *StorageOptions) (Storage, error)

NewStorageFromType creates a storage instance based on type

type StorageOptions

type StorageOptions struct {
	// File storage options
	FilePath string

	// Database storage options
	DatabaseURL string
	TableName   string

	// Redis storage options
	RedisClient *redis.Client
	RedisPrefix string
	RedisTTL    time.Duration
}

StorageOptions holds options for creating storage

type StorageType

type StorageType string

StorageType represents the type of storage backend

const (
	StorageTypeFile     StorageType = "file"
	StorageTypeDatabase StorageType = "database"
	StorageTypeDB       StorageType = "db" // Alias for database
	StorageTypeRedis    StorageType = "redis"
	StorageTypeNone     StorageType = "none"
)

func ParseStorageType

func ParseStorageType(s string) StorageType

ParseStorageType parses a storage type string

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer handles asynchronous writing of audit records to persistent storage

func NewWriter

func NewWriter(storage Storage, config *WriterConfig) *Writer

NewWriter creates a new asynchronous audit writer

func (*Writer) Enqueue

func (w *Writer) Enqueue(record *Record) bool

Enqueue enqueues an audit record for asynchronous writing. Returns false if record is nil, the writer is stopped, or the queue is full (non-blocking). Safe to call after Stop(); will return false instead of panicking.

func (*Writer) GetStats

func (w *Writer) GetStats() Stats

GetStats returns current writer statistics

func (*Writer) OnEnqueueFailed

func (w *Writer) OnEnqueueFailed(fn func(record *Record)) *Writer

OnEnqueueFailed sets a callback for when enqueue fails (queue full)

func (*Writer) OnWriteFailed

func (w *Writer) OnWriteFailed(fn func(record *Record, err error)) *Writer

OnWriteFailed sets a callback for when write fails

func (*Writer) Start

func (w *Writer) Start()

Start starts the writer workers

func (*Writer) Stop

func (w *Writer) Stop() error

Stop stops the writer workers gracefully.

Ordering matters here, and used to be wrong in two ways.

The context was cancelled *before* the queue was drained, so every record still queued was written with a dead context and the storage backend rejected it immediately -- the carefully written drain loop lost everything it was draining.

The queue was also closed while an Enqueue could still be mid-send, because Enqueue checked the stopped flag under the lock and then sent after releasing it. That interleaving panics with "send on closed channel", despite Enqueue documenting itself as safe to call after Stop.

Now: mark stopped and wait for in-flight Enqueue calls to finish (the write lock does that), signal the drain, let workers finish with a live context, and only then cancel and close storage. The queue is never closed.

type WriterConfig

type WriterConfig struct {
	QueueSize   int           // Size of the async queue (default: 1000)
	Workers     int           // Number of worker goroutines (default: 2)
	StopTimeout time.Duration // Timeout for graceful shutdown (default: 10s)
}

WriterConfig holds configuration for the async writer

func DefaultWriterConfig

func DefaultWriterConfig() *WriterConfig

DefaultWriterConfig returns default writer configuration

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL