- Database: kanzlai.audit_log table with RLS, append-only policies (no UPDATE/DELETE), indexes for entity, user, and time queries - Backend: AuditService.Log() with context-based tenant/user/IP/UA extraction, wired into all 7 services (case, deadline, appointment, document, note, party, tenant) - API: GET /api/audit-log with entity_type, entity_id, user_id, from/to date, and pagination filters - Frontend: Protokoll tab on case detail page with chronological audit entries, diff preview, and pagination Required by § 50 BRAO and DSGVO Art. 5(2).
142 lines
3.6 KiB
Go
142 lines
3.6 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
)
|
|
|
|
type AuditService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
func NewAuditService(db *sqlx.DB) *AuditService {
|
|
return &AuditService{db: db}
|
|
}
|
|
|
|
// Log records an audit entry. It extracts tenant, user, IP, and user-agent from context.
|
|
// Errors are logged but not returned — audit logging must not break business operations.
|
|
func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) {
|
|
tenantID, ok := auth.TenantFromContext(ctx)
|
|
if !ok {
|
|
slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType)
|
|
return
|
|
}
|
|
|
|
var userID *uuid.UUID
|
|
if uid, ok := auth.UserFromContext(ctx); ok {
|
|
userID = &uid
|
|
}
|
|
|
|
var oldJSON, newJSON *json.RawMessage
|
|
if oldValues != nil {
|
|
if b, err := json.Marshal(oldValues); err == nil {
|
|
raw := json.RawMessage(b)
|
|
oldJSON = &raw
|
|
}
|
|
}
|
|
if newValues != nil {
|
|
if b, err := json.Marshal(newValues); err == nil {
|
|
raw := json.RawMessage(b)
|
|
newJSON = &raw
|
|
}
|
|
}
|
|
|
|
ip := auth.IPFromContext(ctx)
|
|
ua := auth.UserAgentFromContext(ctx)
|
|
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua)
|
|
if err != nil {
|
|
slog.Error("audit: failed to write log entry",
|
|
"error", err,
|
|
"action", action,
|
|
"entity_type", entityType,
|
|
"entity_id", entityID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// AuditFilter holds query parameters for listing audit log entries.
|
|
type AuditFilter struct {
|
|
EntityType string
|
|
EntityID *uuid.UUID
|
|
UserID *uuid.UUID
|
|
From string // RFC3339 date
|
|
To string // RFC3339 date
|
|
Page int
|
|
Limit int
|
|
}
|
|
|
|
// List returns paginated audit log entries for a tenant.
|
|
func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) {
|
|
if filter.Limit <= 0 {
|
|
filter.Limit = 50
|
|
}
|
|
if filter.Limit > 200 {
|
|
filter.Limit = 200
|
|
}
|
|
if filter.Page <= 0 {
|
|
filter.Page = 1
|
|
}
|
|
offset := (filter.Page - 1) * filter.Limit
|
|
|
|
where := "WHERE tenant_id = $1"
|
|
args := []any{tenantID}
|
|
argIdx := 2
|
|
|
|
if filter.EntityType != "" {
|
|
where += fmt.Sprintf(" AND entity_type = $%d", argIdx)
|
|
args = append(args, filter.EntityType)
|
|
argIdx++
|
|
}
|
|
if filter.EntityID != nil {
|
|
where += fmt.Sprintf(" AND entity_id = $%d", argIdx)
|
|
args = append(args, *filter.EntityID)
|
|
argIdx++
|
|
}
|
|
if filter.UserID != nil {
|
|
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
|
|
args = append(args, *filter.UserID)
|
|
argIdx++
|
|
}
|
|
if filter.From != "" {
|
|
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
|
|
args = append(args, filter.From)
|
|
argIdx++
|
|
}
|
|
if filter.To != "" {
|
|
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
|
|
args = append(args, filter.To)
|
|
argIdx++
|
|
}
|
|
|
|
var total int
|
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil {
|
|
return nil, 0, fmt.Errorf("counting audit entries: %w", err)
|
|
}
|
|
|
|
query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
|
|
where, argIdx, argIdx+1)
|
|
args = append(args, filter.Limit, offset)
|
|
|
|
var entries []models.AuditLog
|
|
if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil {
|
|
return nil, 0, fmt.Errorf("listing audit entries: %w", err)
|
|
}
|
|
if entries == nil {
|
|
entries = []models.AuditLog{}
|
|
}
|
|
|
|
return entries, total, nil
|
|
}
|