package audit // import "git.griefed.de/griefed/repoman/internal/audit"
Package audit provides a typed wrapper around the audit_log table.
All privileged actions (login, logout, user create/edit, config change,
run trigger, emergency reset, etc.) write here. The table is append-only at the
application layer — no Update or Delete methods exist on the Logger.
Why typed actions: hand-typing action strings at every call site invites typos
that silently fragment the audit history. Constants here are the single source
of truth.
Writes are synchronous. The expected event rate is low (a few per second peak),
so blocking the calling handler for a single SQLite INSERT is fine. If that ever
becomes a bottleneck, a buffered channel + worker goroutine can be slotted in
without changing the caller-facing API.
TYPES
type Action string
Action identifies what happened. Stable identifiers for log queries and
metrics.
const (
// User lifecycle
ActionUserCreated Action = "user_created"
ActionUserUpdated Action = "user_updated"
ActionRoleChanged Action = "role_changed"
// Historical values — NEVER written by current code, kept so a reader can
// still resolve rows written before commit 9237697 renamed the lock/unlock
// vocabulary to ActionAccountLocked/ActionAccountUnlocked. audit_log is
// append-only, so those rows were deliberately not rewritten; any consumer
// that filters by action must match the legacy value alongside the current
// one (see LegacyAliases). Do not reuse these identifiers for new events.
ActionUserDeactivated Action = "user_deactivated" // now ActionAccountLocked
ActionUserActivated Action = "user_activated" // now ActionAccountUnlocked
// Auth events that go BOTH to the security log (for fail2ban)
// and here (for the operator-facing UI). The security/audit
// distinction matches the deployment guide split.
ActionLoginSucceeded Action = "login_succeeded"
ActionLoginFailed Action = "login_failed"
ActionLogout Action = "logout"
ActionPasswordChanged Action = "password_changed"
ActionPasswordReset Action = "password_reset"
ActionTwoFactorEnabled Action = "two_factor_enabled"
ActionTwoFactorReset Action = "two_factor_reset"
ActionAccountLocked Action = "account_locked"
ActionAccountUnlocked Action = "account_unlocked"
ActionEmergencyReset Action = "emergency_reset"
ActionPlatformToken Action = "platform_token_updated"
ActionProfileUpdated Action = "profile_updated"
// Migration-config lifecycle
ActionMigrationConfigCreated Action = "migration_config_created"
ActionMigrationConfigUpdated Action = "migration_config_updated"
ActionMigrationConfigDeleted Action = "migration_config_deleted"
ActionMigrationConfigConfirmed Action = "migration_config_confirmed"
// Run lifecycle
ActionRunTriggered Action = "run_triggered"
ActionRunCompleted Action = "run_completed"
ActionRunCancelled Action = "run_cancelled"
// Backup/restore
ActionBackupCreated Action = "backup_created"
ActionRestored Action = "restored"
// Repository export — admin downloads of synced repo mirrors.
ActionRepoDownloaded Action = "repo_downloaded"
// Run-log export — a config's per-run log files downloaded as one .zip for
// debugging. Recorded because the archive names every repository the config
// touches and quotes whatever a source API returned.
ActionRunLogsDownloaded Action = "run_logs_downloaded"
// Custom UI themes (Settings → Themes). Imports log as theme_created
// with a via=import detail.
ActionThemeCreated Action = "theme_created"
ActionThemeUpdated Action = "theme_updated"
ActionThemeDeleted Action = "theme_deleted"
)
func LegacyAliases(action Action) []Action
LegacyAliases returns every identifier under which the given event may
appear in audit_log: the action itself first, followed by any historical
value it was renamed from. A reader that filters by action must use this
rather than the bare constant, or it silently misses rows written before the
rename. Actions that were never renamed return just themselves, so callers
need no special case.
type Entry struct {
Action Action
ActorUserID *int64 // nil for unauthenticated events (failed login)
ActorUsername string // denormalised so log survives user delete
ResourceType string // e.g. "user", "migration_config", "run"
ResourceID string // free-form ID; usually the int64 as text
IP string // remote IP if available
UserAgent string // browser UA if available
Details map[string]string // small flat key/value bag; serialised to JSON
}
Entry is a single audit record waiting to be written. Pass by value to
Logger.Log so the caller does not have to manage a pointer.
At least one of ActorUserID and ActorUsername should be non-empty;
for failed logins where the user is unknown, both can be empty.
type Logger struct {
// Has unexported fields.
}
Logger writes Entry values to the audit_log table.
func New(db *sql.DB) *Logger
New constructs a Logger bound to the given DB. Time defaults to time.Now;
tests override via SetClock.
func (l *Logger) Log(ctx context.Context, entry Entry) error
Log writes the entry. Returns an error so the caller can react if they care
— most callers don't, and treat audit failure as a serious-but-non-blocking
issue. The slog warning is emitted from here so call sites stay terse.
A nil *Logger is a no-op: audit logging is optional, so call sites can hold
a possibly-nil Logger and call it unconditionally rather than guarding every
site.
func (l *Logger) LogIgnoringError(ctx context.Context, entry Entry)
LogIgnoringError is a convenience wrapper for callers that want
fire-and-forget semantics. Use only for events where a missing audit entry
is preferable to a returned error (e.g. login-failed inside an HTTP handler
that needs to keep responding).
func (l *Logger) SetClock(now func() time.Time)
SetClock replaces the time source. Test-only.