// Package auditlog records administrative changes made through the web UI, the // REST API and the CLI. // // Audit writes are rare compared to DNS queries, so they are synchronous and // best-effort: a failure to record an audit entry is logged but never fails the // operation the administrator asked for. package auditlog import ( "context" "fmt" "log/slog" "strconv" "strings" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" ) // Sources an audit entry can originate from. const ( SourceWeb = "web" SourceAPI = "api" SourceCLI = "cli" SourceSystem = "system" ) // Object types recorded in the audit log. const ( ObjectZone = "zone" ObjectRecord = "record" ObjectNetwork = "network" ObjectPolicy = "policy" ObjectList = "list" ObjectDomain = "domain" ObjectSettings = "settings" ObjectCache = "cache" ObjectAdmin = "admin" ObjectToken = "api_token" ObjectBackup = "backup" ObjectConfig = "config" ObjectQueryLog = "query_log" ) // Logger writes audit entries. type Logger struct { db *database.DB log *slog.Logger } // New creates an audit logger. func New(db *database.DB, log *slog.Logger) *Logger { return &Logger{db: db, log: log} } // Actor identifies who performed an action and from where. type Actor struct { Name string Source string ClientIP string } // SystemActor is used for changes the server makes on its own behalf. func SystemActor() Actor { return Actor{Name: "system", Source: SourceSystem} } // CLIActor is used for changes made through the command line. func CLIActor() Actor { return Actor{Name: "cli", Source: SourceCLI} } // Record writes an audit entry. Secrets must never be passed in details: this // is enforced by convention at the call sites, which pass names and counts // rather than values. func (l *Logger) Record(ctx context.Context, a Actor, action, objectType, objectID, objectName, details string) { if l == nil || l.db == nil { return } e := models.AuditEntry{ Actor: a.Name, Source: defaultString(a.Source, SourceWeb), ClientIP: a.ClientIP, Action: action, ObjectType: objectType, ObjectID: objectID, ObjectName: objectName, Details: truncate(details, 2000), } if err := l.db.InsertAudit(ctx, e); err != nil { l.log.Error("could not write audit entry", "error", err, "action", action) } } // RecordID is Record with an integer object ID. func (l *Logger) RecordID(ctx context.Context, a Actor, action, objectType string, id int64, objectName, details string) { l.Record(ctx, a, action, objectType, strconv.FormatInt(id, 10), objectName, details) } // Changes renders a set of field changes as an audit detail string. func Changes(pairs ...string) string { if len(pairs)%2 != 0 { return strings.Join(pairs, " ") } var parts []string for i := 0; i < len(pairs); i += 2 { parts = append(parts, fmt.Sprintf("%s=%s", pairs[i], pairs[i+1])) } return strings.Join(parts, ", ") } func defaultString(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }