NextMyDay/internal/core/audit/audit.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

151 lines
4.7 KiB
Go

// Package audit is MyDay's append-only security trail (A11).
//
// Every auth-sensitive event — bootstrap, enroll, login, logout, recovery
// request/approve/deny, revoke, role change — is recorded here, together with
// the client IP and (IP-derived, D-031) location. The trail outlives the users
// it names, so it carries no foreign keys.
//
// Spec: docs/specs/spec-004-identity-sessions.md §5.
package audit
import (
"context"
"database/sql"
"log/slog"
"time"
)
// Event is one audit entry. Secrets are NEVER placed in Detail or Subject
// (A9) — those columns are for identifiers and outcomes only.
type Event struct {
// ActorUserID is the authenticated principal responsible for the event;
// 0 means anonymous/system (stored as NULL).
ActorUserID int64
// Action is the event kind, e.g. "user.create", "identity.rotate".
Action string
// Subject is what the event acted on, e.g. "user:2" or "identity:5".
Subject string
// Detail is non-secret context, e.g. "role=manager".
Detail string
// ClientIP is the resolved client address (A6); optional.
ClientIP string
// Location is the approximate, IP-derived location — AUDIT ONLY (D-031).
Location string
// CorrelationID ties the event to its request (SPEC-003).
CorrelationID string
}
// Audit records events. The contract is deliberately fire-and-forget — the
// audit trail must never break a request.
//
// Per ruling R-1 (SPEC-004 §5) the event is recorded WITH the request context:
// the door attaches Meta before any service call, and services write their
// audit rows through it, so actor · client IP · correlation are never lost.
type Audit interface {
Record(ctx context.Context, e Event)
}
// Meta is the per-request audit metadata the door attaches to the
// context.Context before calling any service (SPEC-004 §5, ruling R-1;
// SPEC-003 §4). It is IDENTIFICATION and CONTEXT only — never a credential.
type Meta struct {
// ActorUserID is the authenticated principal responsible for the request;
// 0 means anonymous/system.
ActorUserID int64
// ClientIP is the resolved client address (A6).
ClientIP string
// CorrelationID ties every event of one request together (SPEC-003 §4).
CorrelationID string
}
// metaKey is the unexported context key for Meta; using a struct type prevents
// collisions with other packages' keys.
type metaKey struct{}
// WithMeta attaches m to ctx. The door calls this once, before handing off to a
// module or a service.
func WithMeta(ctx context.Context, m Meta) context.Context {
return context.WithValue(ctx, metaKey{}, m)
}
// MetaFrom returns the Meta attached to ctx, if any.
func MetaFrom(ctx context.Context) (Meta, bool) {
m, ok := ctx.Value(metaKey{}).(Meta)
return m, ok
}
// Merge fills an event's empty context fields from the metadata on ctx, so a
// service never has to re-thread actor · client IP · correlation by hand.
//
// Explicit event values WIN — a service that knows the actor better than the
// ambient request context (e.g. the identity owner) may set it and it is kept
// (ruling R-3).
func Merge(ctx context.Context, e Event) Event {
m, ok := MetaFrom(ctx)
if !ok {
return e
}
if e.ActorUserID == 0 {
e.ActorUserID = m.ActorUserID
}
if e.ClientIP == "" {
e.ClientIP = m.ClientIP
}
if e.CorrelationID == "" {
e.CorrelationID = m.CorrelationID
}
return e
}
// Log writes events to the audit_log table.
type Log struct {
db *sql.DB
now func() time.Time
}
// New returns an Audit writing to db. time.Now is injectable for tests via the
// unexported field (same package).
func New(db *sql.DB) *Log {
return &Log{db: db, now: time.Now}
}
// Record inserts one audit row. A failure is logged, never returned: an
// un-writable audit trail must not take the application down (but it is loud).
//
// The event is merged with the Meta carried on ctx first (ruling R-1), so the
// actor · client IP · correlation attached by the door reach the row.
func (l *Log) Record(ctx context.Context, e Event) {
e = Merge(ctx, e)
var actor any
if e.ActorUserID != 0 {
actor = e.ActorUserID
}
_, err := l.db.Exec(
`INSERT INTO audit_log
(at, actor_user_id, action, subject, detail, client_ip, location, correlation_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
l.now().UTC().Truncate(time.Second).Format(time.RFC3339),
actor,
e.Action,
nullString(e.Subject),
nullString(e.Detail),
nullString(e.ClientIP),
nullString(e.Location),
nullString(e.CorrelationID),
)
if err != nil {
slog.Error("audit: record failed", "action", e.Action, "error", err)
}
}
// nullString maps "" to NULL so the trail distinguishes "absent" from "empty".
func nullString(s string) any {
if s == "" {
return nil
}
return s
}
var _ Audit = (*Log)(nil)