Files
paliad/internal/services/audit_service.go
m 0f87d73b1b feat(t-paliad-154) commit 3/5: HTTP handlers — admin APIs + form-hint endpoint + audit-log union
8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):

Admin APIs (gated by adminGate):
- GET    /admin/approval-policies                                                  — page shell
- GET    /api/admin/partner-units/{unit_id}/approval-policies                      — list unit defaults
- PUT    /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET    /api/admin/approval-policies/seeded                                       — exists check (gates inbox nudge)
- GET    /api/admin/approval-policies/matrix?project_id=...                        — 8 effective rows w/ attribution
- POST   /api/admin/approval-policies/apply-to-descendants                         — bulk fanout

Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=

AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
  packs description as 'entity/lifecycle: old → new'. project_id forwarded
  for project-scoped rows so /admin/audit-log filters work — but
  policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
  ProjectService.ListProjectEvents reads project_events directly), so
  Q8's no-leak constraint is preserved.

Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
2026-05-08 02:22:19 +02:00

282 lines
11 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)
//
// 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"
)
// 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)
)
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
}