Files
keywarden/internal/audit/audit.go

290 lines
8.7 KiB
Go

// 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"
ActionBrandingChanged = "branding_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
}