// Keywarden - Centralized SSH Key Management and Deployment // Copyright (C) 2026 Patrick Asmus (scriptos) // SPDX-License-Identifier: AGPL-3.0-or-later package audit import ( "database/sql" "fmt" "git.techniverse.net/scriptos/keywarden/internal/database" "git.techniverse.net/scriptos/keywarden/internal/logging" "git.techniverse.net/scriptos/keywarden/internal/models" ) // Action constants for audit logging const ( // Authentication ActionLoginSuccess = "login_success" ActionLoginFailed = "login_failed" ActionLogout = "logout" ActionMFASuccess = "mfa_verified" ActionMFAFailed = "mfa_failed" ActionMFAEnabled = "mfa_enabled" ActionMFADisabled = "mfa_disabled" // SSH Keys ActionKeyGenerated = "key_generated" ActionKeyImported = "key_imported" ActionKeyDeleted = "key_deleted" ActionKeyDownload = "key_downloaded" // Servers ActionServerAdded = "server_added" ActionServerUpdated = "server_updated" ActionServerDeleted = "server_deleted" ActionServerTest = "server_test" ActionServerAuth = "server_auth_test" // Server Groups ActionGroupCreated = "group_created" ActionGroupUpdated = "group_updated" ActionGroupDeleted = "group_deleted" ActionGroupServerAdded = "group_server_added" ActionGroupServerRemoved = "group_server_removed" ActionGroupDeploy = "group_deploy" // Deployments ActionDeploySuccess = "deploy_success" ActionDeployFailed = "deploy_failed" // User Management (admin) ActionUserCreated = "user_created" ActionUserUpdated = "user_updated" ActionUserDeleted = "user_deleted" // Settings ActionSettingsChanged = "settings_changed" ActionPasswordChanged = "password_changed" ActionMasterKeyRegen = "masterkey_regenerated" ActionMasterKeyRegenerated = "masterkey_regenerated" ActionMasterKeyRegenFailed = "masterkey_regen_failed" ActionAvatarChanged = "avatar_changed" // Email ActionEmailNotifyChanged = "email_notify_changed" ActionEmailTestSent = "email_test_sent" ActionEmailTestFailed = "email_test_failed" ActionEmailLoginSent = "email_login_sent" ActionEmailLoginFailed = "email_login_failed" // Access Assignments ActionAssignmentCreated = "assignment_created" ActionAssignmentUpdated = "assignment_updated" ActionAssignmentDeleted = "assignment_deleted" ActionAssignmentSynced = "assignment_synced" ActionAssignmentSyncFailed = "assignment_sync_failed" ActionAssignmentKeyRemoved = "assignment_key_removed" ActionAssignmentUserDeleted = "assignment_user_deleted" ActionAssignmentCleanFailed = "assignment_cleanup_failed" // Cron Jobs ActionCronJobCreated = "cron_job_created" ActionCronJobUpdated = "cron_job_updated" ActionCronJobDeleted = "cron_job_deleted" ActionCronJobPaused = "cron_job_paused" ActionCronJobResumed = "cron_job_resumed" ActionCronJobExecuted = "cron_job_executed" ActionCronJobFailed = "cron_job_failed" ActionCronJobKeyRemoved = "cron_job_key_removed" // Account Security ActionAccountLocked = "account_locked" ActionAccountUnlocked = "account_unlocked" ActionForcePasswordChange = "force_password_change" ActionMFAEnforced = "mfa_enforced" ActionPasswordPolicyChanged = "password_policy_changed" // Backup & Restore ActionBackupExported = "backup_exported" ActionBackupExportFailed = "backup_export_failed" ActionBackupImported = "backup_imported" ActionBackupImportFailed = "backup_import_failed" // Invitations ActionInvitationSent = "invitation_sent" ActionInvitationSendFailed = "invitation_send_failed" ActionInvitationAccepted = "invitation_accepted" ActionInvitationFailed = "invitation_failed" // Key Enforcement ActionEnforcementRun = "enforcement_run" ActionEnforcementDrift = "enforcement_drift" ActionEnforcementApplied = "enforcement_applied" ActionEnforcementFailed = "enforcement_failed" ActionEnforcementSettings = "enforcement_settings_changed" ) // AuditEntry extends AuditLog with the username for display type AuditEntry struct { models.AuditLog Username string } // Service handles audit log operations type Service struct { db *database.DB } // NewService creates a new audit service func NewService(db *database.DB) *Service { return &Service{db: db} } // Log records an audit event. Errors are logged but never returned to avoid // disrupting the main application flow. func (s *Service) Log(userID int64, action, details, ipAddress string) { var uid interface{} if userID == 0 { uid = sql.NullInt64{Valid: false} } else { uid = userID } logging.Debug("Audit: action=%s user_id=%d ip=%s details=%s", action, userID, ipAddress, details) _, err := s.db.Exec( `INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`, uid, action, details, ipAddress, ) if err != nil { logging.Warn("Failed to write audit log: %v", err) } } // GetAll returns paginated audit entries for all users (admin view). // Returns entries, total count, and error. func (s *Service) GetAll(page, perPage int) ([]AuditEntry, int, error) { if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } var total int if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&total); err != nil { return nil, 0, fmt.Errorf("failed to count audit logs: %w", err) } offset := (page - 1) * perPage rows, err := s.db.Query( `SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username, a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''), a.created_at FROM audit_log a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC LIMIT ? OFFSET ?`, perPage, offset, ) if err != nil { return nil, 0, fmt.Errorf("failed to query audit logs: %w", err) } defer rows.Close() var entries []AuditEntry for rows.Next() { var e AuditEntry if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil { return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err) } entries = append(entries, e) } return entries, total, nil } // GetAllExceptOwners returns paginated audit entries excluding entries from // users with the "owner" role. This is the admin view. func (s *Service) GetAllExceptOwners(page, perPage int) ([]AuditEntry, int, error) { if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } var total int if err := s.db.QueryRow( `SELECT COUNT(*) FROM audit_log a LEFT JOIN users u ON u.id = a.user_id WHERE u.role IS NULL OR u.role != 'owner'`, ).Scan(&total); err != nil { return nil, 0, fmt.Errorf("failed to count audit logs: %w", err) } offset := (page - 1) * perPage rows, err := s.db.Query( `SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username, a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''), a.created_at FROM audit_log a LEFT JOIN users u ON u.id = a.user_id WHERE u.role IS NULL OR u.role != 'owner' ORDER BY a.created_at DESC LIMIT ? OFFSET ?`, perPage, offset, ) if err != nil { return nil, 0, fmt.Errorf("failed to query audit logs: %w", err) } defer rows.Close() var entries []AuditEntry for rows.Next() { var e AuditEntry if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil { return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err) } entries = append(entries, e) } return entries, total, nil } // GetByUser returns paginated audit entries for a specific user. func (s *Service) GetByUser(userID int64, page, perPage int) ([]AuditEntry, int, error) { if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } var total int if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id = ?`, userID).Scan(&total); err != nil { return nil, 0, fmt.Errorf("failed to count audit logs: %w", err) } offset := (page - 1) * perPage rows, err := s.db.Query( `SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(deleted)') AS username, a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''), a.created_at FROM audit_log a LEFT JOIN users u ON u.id = a.user_id WHERE a.user_id = ? ORDER BY a.created_at DESC LIMIT ? OFFSET ?`, userID, perPage, offset, ) if err != nil { return nil, 0, fmt.Errorf("failed to query audit logs: %w", err) } defer rows.Close() var entries []AuditEntry for rows.Next() { var e AuditEntry if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil { return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err) } entries = append(entries, e) } return entries, total, nil }