// Package audit writes the append only audit trail. It wraps dbgen.InsertAuditLog, which targets the // audit_logs table (outside RLS, trigger enforced append only, app granted INSERT and SELECT only). // // Two write paths exist. Org scoped events run on the caller's tenant transaction so the audit row // commits or rolls back with the work it records. Pre auth and platform security events (failed // login, session rejected) run off the pool with a NULL org, because no tenant context exists yet. // Both return an error, and security events MUST fail closed: a login the audit cannot record is // rejected rather than allowed silently unaudited. package audit import ( "context" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when the writer has no pool to record a system event. var ErrNoDatabase = errors.New("audit: no database configured") // Event is one audit record. Action is required (for example login.success, auth.session.rejected, // privacy.erase). The rest are optional context; empty strings are stored as NULL. type Event struct { Action string ActorUserID *uuid.UUID EntityType string EntityID string OldValues []byte NewValues []byte IPAddress string UserAgent string } // Writer records audit events. type Writer struct{ db *tenant.DB } // New builds a Writer. db may be nil, in which case System returns ErrNoDatabase. func New(db *tenant.DB) *Writer { return &Writer{db: db} } // OnTx records an org scoped event on the caller's tenant transaction. The org is read from the // validated context, never a caller argument, so it inherits the same authoritative org the session // resolver set. func (w *Writer) OnTx(ctx context.Context, tx pgx.Tx, e Event) error { var org *uuid.UUID if raw, ok := tenant.OrgFromContext(ctx); ok { if id, err := uuid.Parse(raw); err == nil { org = &id } } return insert(ctx, tx, org, e) } // System records a pre auth or platform security event off the pool with a NULL org. The returned // error is meaningful: the caller fails the request closed when a security event cannot be written. func (w *Writer) System(ctx context.Context, e Event) error { if w.db == nil || w.db.Pool == nil { return ErrNoDatabase } return insert(ctx, w.db.Pool, nil, e) } // OnPool records an event off a raw pool for a KNOWN org. It exists for the ops commands that run outside a // request and therefore have no session and no tenant context to read the org from. The org is a parameter // here rather than read from the context, which is acceptable in exactly this case and nowhere else: a // command iterating tenants has no session to inherit, and the alternative is a NULL org on a row that // plainly belongs to one. // // It reuses insert rather than writing a second INSERT, so a row written by a command cannot drift in shape // from a row written by a request. func OnPool(ctx context.Context, db dbgen.DBTX, org *uuid.UUID, e Event) error { if db == nil { return ErrNoDatabase } return insert(ctx, db, org, e) } func insert(ctx context.Context, db dbgen.DBTX, org *uuid.UUID, e Event) error { _, err := dbgen.New(db).InsertAuditLog(ctx, dbgen.InsertAuditLogParams{ OrganizationID: org, ActorUserID: e.ActorUserID, Action: e.Action, EntityType: strPtr(e.EntityType), EntityID: strPtr(e.EntityID), OldValues: e.OldValues, NewValues: e.NewValues, IpAddress: strPtr(e.IPAddress), UserAgent: strPtr(e.UserAgent), }) return err } func strPtr(s string) *string { if s == "" { return nil } return &s }