← repoman internals

internal/audit

import "git.griefed.de/griefed/repoman/internal/audit"
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.