- 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)
151 lines
4.7 KiB
Go
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)
|