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.
305 lines
12 KiB
Go
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
|
|
}
|