Files
VoCat/internal/server/audit.go
T
MengMengCode 688e1e8311 feat(logging): implement log retention policy with hard limit and exclusion filters
- Added MaxLogEvents constant to enforce a hard limit on stored log events.
- Updated AppendLogEvent to discard older logs when the limit is exceeded.
- Enhanced ListLogEvents to support filtering by log level and excluding specific messages.
- Introduced ClearLogEvents method to permanently remove logs and prevent re-queuing of cleared entries.
- Modified LogRetentionCard component to reflect the new log retention settings and limits.
- Added logging categories for better organization and filtering in the UI.
- Implemented sanitization for sensitive information in logs.
- Added tests for log event limits and clearing functionality.
2026-08-21 01:01:41 +08:00

101 lines
2.9 KiB
Go

package server
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"time"
"vocat/internal/store"
)
// recordAudit writes one security-relevant event to the audit trail. Failures
// are logged but never block the request being audited.
func (s *Server) recordAudit(
ctx context.Context,
actor string,
action string,
entityType string,
entityID string,
outcome string,
remoteAddr string,
) {
level := slog.LevelInfo
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
level = slog.LevelWarn
}
if s.logger != nil {
s.logger.Log(ctx, level, "user operation",
"category", auditLogCategory(action),
"event", action,
"actor", actor,
"entity_type", entityType,
"entity_id", entityID,
"outcome", outcome,
)
}
if s.store == nil {
return
}
_, err := s.store.AppendAuditEvent(ctx, store.AuditEvent{
Actor: actor,
Action: action,
EntityType: entityType,
EntityID: entityID,
Outcome: outcome,
RemoteAddr: remoteAddr,
CreatedAt: time.Now().UTC(),
})
if err != nil {
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
}
}
func auditLogCategory(action string) string {
action = strings.ToLower(strings.TrimSpace(action))
switch {
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
return "sms"
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
return "call"
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
return "vowifi"
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
return "hardware"
default:
return "operation"
}
}
// audit records an event for an already-authenticated request, resolving the
// actor from the session and the source address from the raw connection (proxy
// headers are deliberately not trusted for the audit trail).
func (s *Server) audit(r *http.Request, action string, entityType string, entityID string, outcome string) {
actor := ""
if s.auth != nil {
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
if session, authErr := s.auth.Authenticate(r.Context(), cookie.Value); authErr == nil {
actor = session.Principal.Username
}
}
}
s.recordAudit(r.Context(), actor, action, entityType, entityID, outcome, requestRemoteHost(r))
}
// auditAuth records an authentication event where no session exists yet (the
// actor is the username that was attempted).
func (s *Server) auditAuth(r *http.Request, username string, outcome string) {
s.recordAudit(r.Context(), username, "auth.login", "session", username, outcome, requestRemoteHost(r))
}
func requestRemoteHost(r *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}