Files
2026-08-16 21:18:45 -05:00

121 lines
3.1 KiB
Go

// 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] + "..."
}