Files
paliad/internal/services/audit_service.go
mAi 28c7215458 feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the
caller's RLS-visible projection (per design §2.3): projects, deadlines,
appointments, parties, notes, documents (metadata), audit events,
approval requests, checklist instances + personal sidecars (me row,
caldav config without ciphertext, views, pins, card layouts, paliadin
turns) + reference data (proceeding_types, event_types, deadline_rules,
courts, countries, holidays …) + restricted users_referenced sheet.

Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet
CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is
byte-deterministic — sorted file list, fixed Modified time on every
entry, sorted JSON keys. Two runs at same row-state → identical bytes.

ExportService.WritePersonal owns the SQL recipe + column discovery
+ PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key
+ per-sheet DropColumns belt-and-braces (e.g. user_caldav_config
.password_encrypted explicitly dropped on top of the regex). Audit row
written to paliad.system_audit_log before the run, patched with
row_counts + file_size_bytes after.

Migration 102 creates paliad.system_audit_log (generic event_type +
actor_id/email + scope + scope_root + metadata jsonb). Idempotent
CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read +
admin-read policies. AuditService.ListEntries gains a 6th UNION branch
so the new table surfaces on /admin/audit-log.

excelize/v2 added to go.mod for xlsx generation.

Pure-function tests pin formatCellValue value-coercion, PII regex,
CSV quoting + BOM + umlaut survival, JSON shape, meta key order
stability, filename slugify, and byte-determinism of the bundle
assembly.

Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1.
2026-05-19 12:51:52 +02:00

305 lines
12 KiB
Go

package services
// AuditService produces a unified, paginated, filterable timeline across
// every audit source we keep in the paliad schema. There is no single
// audit_log table — instead we union five sources:
//
// - paliad.project_events — per-project audit (creates, updates, etc.)
// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user
// - paliad.reminder_log — bundled-digest reminder sends
// - paliad.partner_unit_events — partner-unit CRUD + membership changes
// - paliad.policy_audit_log — approval-policy CRUD (t-paliad-154)
// - paliad.system_audit_log — org-wide / scope-spanning actions (t-paliad-214)
//
// The union happens in SQL (one round-trip, server-side ordering) and is
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
// page boundaries even when new rows arrive at the head.
//
// Rendering decisions (event_type → human label, value-only → narrative)
// stay on the frontend so the i18n logic the dashboard already uses
// (translateEvent) can be reused. We send the raw event_type plus the raw
// stored title/description columns and let the client localise.
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Audit source discriminators. Stable strings — exposed in the JSON payload,
// referenced in the i18n keys, and used as filter values.
const (
AuditSourceProjectEvents = "project_events"
AuditSourceCalDAVLog = "caldav_sync_log"
AuditSourceReminderLog = "reminder_log"
AuditSourcePartnerUnitEvents = "partner_unit_events"
AuditSourcePolicyAuditLog = "policy_audit_log"
AuditSourceSystemAuditLog = "system_audit_log"
)
// MaxAuditPageLimit caps a single ListEntries page.
const MaxAuditPageLimit = 200
// DefaultAuditPageLimit is the page size when the caller doesn't specify.
const DefaultAuditPageLimit = 50
// AuditEntry is one unified row in the global audit timeline.
type AuditEntry struct {
Timestamp time.Time `db:"ts" json:"timestamp"`
ID uuid.UUID `db:"id" json:"id"`
Source string `db:"source" json:"source"`
EventType string `db:"event_type" json:"event_type"`
Actor string `db:"actor" json:"actor"`
Subject string `db:"subject" json:"subject"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
Title *string `db:"title" json:"title,omitempty"`
Description *string `db:"description" json:"description,omitempty"`
}
// AuditFilter is the input to ListEntries.
type AuditFilter struct {
// Source restricts to one source ("" = all).
Source string
// From / To restrict to an inclusive timestamp range. Zero values mean
// "no bound on that side" — pass time.Time{} to skip.
From time.Time
To time.Time
// Search is a case-insensitive ILIKE match against subject + description
// + title. Empty means no text filter.
Search string
// BeforeTS / BeforeID are the keyset cursor — return rows strictly older
// than (BeforeTS, BeforeID). Both must be set to use cursor pagination;
// if either is zero/nil the cursor is ignored.
BeforeTS *time.Time
BeforeID *uuid.UUID
// Limit caps the page size. 0 → DefaultAuditPageLimit, capped at
// MaxAuditPageLimit.
Limit int
}
// AuditService unions paliad.project_events / caldav_sync_log / reminder_log
// into one timeline. Read-only — the rows themselves are written by the
// services that own each source.
type AuditService struct {
db *sqlx.DB
}
func NewAuditService(db *sqlx.DB) *AuditService {
return &AuditService{db: db}
}
// auditUnionSQL is the keyset-paginated UNION of all three audit sources.
//
// Each subquery normalises its row into the AuditEntry shape so the outer
// SELECT can ORDER BY a single (ts, id) tuple. Filters that are agnostic
// across sources (search, cursor) live in the outer query; per-source
// filters (date range, source restriction) live in each subquery so PG can
// prune sources via the WHERE before the union materialises.
//
// Parameters (positional):
//
// $1 source (text) — filter to one source, or "" for all
// $2 from (timestamptz) — inclusive lower bound, NULL = unbounded
// $3 to (timestamptz) — inclusive upper bound, NULL = unbounded
// $4 search (text) — ILIKE substring against subject/description/title, NULL = none
// $5 cursor_ts (timestamptz) — keyset upper bound, NULL = first page
// $6 cursor_id (uuid) — keyset tiebreaker, NULL = first page
// $7 limit (int)
const auditUnionSQL = `
WITH unioned AS (
SELECT
'project_events'::text AS source,
e.id AS id,
COALESCE(e.event_date, e.created_at) AS ts,
COALESCE(e.event_type, 'event') AS event_type,
COALESCE(u.email, 'system') AS actor,
COALESCE(p.title, '—') AS subject,
e.project_id AS project_id,
e.title AS title,
e.description AS description
FROM paliad.project_events e
LEFT JOIN paliad.users u ON u.id = e.created_by
LEFT JOIN paliad.projects p ON p.id = e.project_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'project_events')
AND ($2::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) >= $2)
AND ($3::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) <= $3)
UNION ALL
SELECT
'caldav_sync_log'::text AS source,
c.id AS id,
c.occurred_at AS ts,
CASE
WHEN c.error IS NOT NULL THEN 'caldav_sync_error'
ELSE 'caldav_synced'
END AS event_type,
'system'::text AS actor,
COALESCE(u.email, c.user_id::text) AS subject,
NULL::uuid AS project_id,
NULL::text AS title,
COALESCE(
c.error,
format('direction=%s pushed=%s pulled=%s duration_ms=%s',
c.direction, c.items_pushed, c.items_pulled,
COALESCE(c.duration_ms, 0))
) AS description
FROM paliad.caldav_sync_log c
LEFT JOIN paliad.users u ON u.id = c.user_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'caldav_sync_log')
AND ($2::timestamptz IS NULL OR c.occurred_at >= $2)
AND ($3::timestamptz IS NULL OR c.occurred_at <= $3)
UNION ALL
SELECT
'reminder_log'::text AS source,
r.id AS id,
r.sent_at AS ts,
r.reminder_type AS event_type,
'system'::text AS actor,
COALESCE(u.email, r.user_id::text) AS subject,
NULL::uuid AS project_id,
NULL::text AS title,
format('slot=%s slot_date=%s',
COALESCE(r.slot, ''),
COALESCE(r.slot_date::text, '')) AS description
FROM paliad.reminder_log r
LEFT JOIN paliad.users u ON u.id = r.user_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'reminder_log')
AND ($2::timestamptz IS NULL OR r.sent_at >= $2)
AND ($3::timestamptz IS NULL OR r.sent_at <= $3)
UNION ALL
SELECT
'partner_unit_events'::text AS source,
pue.id AS id,
pue.created_at AS ts,
pue.event_type AS event_type,
COALESCE(au.email, pue.actor_id::text) AS actor,
pue.unit_name AS subject,
NULL::uuid AS project_id,
NULL::text AS title,
pue.payload::text AS description
FROM paliad.partner_unit_events pue
LEFT JOIN paliad.users au ON au.id = pue.actor_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events')
AND ($2::timestamptz IS NULL OR pue.created_at >= $2)
AND ($3::timestamptz IS NULL OR pue.created_at <= $3)
UNION ALL
-- t-paliad-154 — approval-policy CRUD audit. Project-scoped rows carry
-- project_id (so the timeline filter by project still works on the
-- /verlauf SELECT — but project_events is the source for that surface,
-- not policy_audit_log, so no leakage). Description packs the field
-- transition (entity_type/lifecycle: old → new).
SELECT
'policy_audit_log'::text AS source,
pal.id AS id,
pal.created_at AS ts,
pal.event_type AS event_type,
COALESCE(au.email, pal.actor_id::text) AS actor,
pal.scope_name AS subject,
pal.project_id AS project_id,
NULL::text AS title,
format('%s/%s: %s → %s',
pal.entity_type,
pal.lifecycle_event,
COALESCE(pal.old_required_role, '—'),
COALESCE(pal.new_required_role, '—')) AS description
FROM paliad.policy_audit_log pal
LEFT JOIN paliad.users au ON au.id = pal.actor_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log')
AND ($2::timestamptz IS NULL OR pal.created_at >= $2)
AND ($3::timestamptz IS NULL OR pal.created_at <= $3)
UNION ALL
-- t-paliad-214 — org-wide / scope-spanning actions. First user is the
-- data-export audit chain. scope_root is the project_id for
-- scope='project'; NULL otherwise. project_id forwarded so timeline
-- filtering by project surfaces project-scope exports too.
SELECT
'system_audit_log'::text AS source,
sal.id AS id,
sal.created_at AS ts,
sal.event_type AS event_type,
sal.actor_email AS actor,
COALESCE(sal.scope, 'system') AS subject,
sal.scope_root AS project_id,
NULL::text AS title,
sal.metadata::text AS description
FROM paliad.system_audit_log sal
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'system_audit_log')
AND ($2::timestamptz IS NULL OR sal.created_at >= $2)
AND ($3::timestamptz IS NULL OR sal.created_at <= $3)
)
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
FROM unioned
WHERE ($4::text IS NULL OR (
subject ILIKE '%' || $4 || '%'
OR COALESCE(description, '') ILIKE '%' || $4 || '%'
OR COALESCE(title, '') ILIKE '%' || $4 || '%'
OR event_type ILIKE '%' || $4 || '%'
OR actor ILIKE '%' || $4 || '%'
))
AND ($5::timestamptz IS NULL OR (ts, id) < ($5, $6::uuid))
ORDER BY ts DESC, id DESC
LIMIT $7
`
// ListEntries returns one page of audit rows matching the filter. Caller is
// expected to have already verified the user is a global_admin — the service
// itself does no role enforcement (the gate happens at the route layer via
// auth.RequireAdminFunc).
func (s *AuditService) ListEntries(ctx context.Context, f AuditFilter) ([]AuditEntry, error) {
limit := f.Limit
if limit <= 0 {
limit = DefaultAuditPageLimit
}
if limit > MaxAuditPageLimit {
limit = MaxAuditPageLimit
}
// Convert zero-values to typed nulls so the SQL's COALESCE-style guards
// behave correctly. database/sql treats nil interface{} as NULL, but a
// zero time.Time would be sent as '0001-01-01' which is a valid value
// PG would actually compare against — silently shifting all results.
var sourceArg, searchArg any
if f.Source != "" {
sourceArg = f.Source
}
if s := strings.TrimSpace(f.Search); s != "" {
// Escape SQL LIKE metacharacters so a search for "100%" doesn't act
// as a wildcard. Backslash is the default LIKE escape in PG.
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
searchArg = escaped
}
var fromArg, toArg any
if !f.From.IsZero() {
fromArg = f.From
}
if !f.To.IsZero() {
toArg = f.To
}
var cursorTS, cursorID any
if f.BeforeTS != nil && f.BeforeID != nil {
cursorTS = *f.BeforeTS
cursorID = *f.BeforeID
}
var rows []AuditEntry
if err := s.db.SelectContext(ctx, &rows, auditUnionSQL,
sourceArg, fromArg, toArg, searchArg, cursorTS, cursorID, limit,
); err != nil {
return nil, fmt.Errorf("audit list: %w", err)
}
return rows, nil
}