// 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)