feat(t-paliad-154) commit 1.5: extend migration 062 with policy_audit_log

Q8 of locked design: policy CRUD audits to /admin/audit-log only, NOT
to per-project /verlauf. The 4 existing audit sources (project_events,
caldav_sync_log, reminder_log, partner_unit_events) don't fit cleanly:
project_events would surface on /verlauf (rejected by Q8); partner_unit_events
constrains event_type and requires unit_name + a non-null partner_unit_id
which doesn't fit project-scoped policy changes.

Added paliad.policy_audit_log as a fifth audit source — admin-only, scoped
either to a project or a partner unit, snapshots scope_name so post-cascade
rows still render. RLS: select for any authenticated user (route gate is
the actual control); write for global_admin only.

AuditService.ListEntries will union this source in commit 2 of this PR.

Validated insert/select live in BEGIN ... ROLLBACK.
This commit is contained in:
m
2026-05-08 02:13:58 +02:00
parent f7908f03ad
commit e92c56b5f8
2 changed files with 75 additions and 0 deletions

View File

@@ -1,12 +1,16 @@
-- t-paliad-154 down migration. Reverses 062_approval_policy_unit_defaults.up.sql.
--
-- Order is the reverse of up:
-- 0. Drop policy_audit_log table.
-- 1. Drop seeded unit-default rows (anything where partner_unit_id IS NOT NULL).
-- 2. Drop the resolver function.
-- 3. Restore required_role CHECK without 'none'.
-- 4. Drop the two partial unique indexes + restore the original UNIQUE composite.
-- 5. Drop XOR check + partner_unit_id column.
-- 6. Restore project_id NOT NULL.
-- 0. Drop the audit table.
DROP TABLE IF EXISTS paliad.policy_audit_log;
--
-- Best-effort reversibility: any project-specific row with required_role='none'
-- will fail the CHECK restoration. We coerce those to 'associate' before

View File

@@ -15,6 +15,10 @@
-- ancestor + unit_default candidates.
-- 4. Conservative seed defaults for every existing partner_unit:
-- deadline+appointment × create/update/delete=associate, complete=none.
-- 5. paliad.policy_audit_log — fifth audit source for /admin/audit-log only.
-- Q8 of the locked design: policy changes audit on /admin/audit-log,
-- NOT on per-project /verlauf. Distinct table keeps the verlauf union
-- clean while letting AuditService union the new source.
--
-- Idempotent on re-run (ON CONFLICT DO NOTHING on the seed). Safe on a DB
-- with 0 partner_units (the seed simply inserts zero rows).
@@ -25,6 +29,7 @@
-- 3. Extend required_role CHECK with 'none' sentinel.
-- 4. CREATE FUNCTION paliad.approval_policy_effective(uuid, text, text).
-- 5. Seed conservative defaults for every existing partner_unit.
-- 6. CREATE TABLE paliad.policy_audit_log (admin-only audit source).
-- ============================================================================
-- 1. project_id becomes nullable; add partner_unit_id; XOR check.
@@ -212,3 +217,69 @@ SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO NOTHING;
-- ============================================================================
-- 6. paliad.policy_audit_log — admin-only audit source.
--
-- Q8 of the design: emit audit on /admin/audit-log, NOT on per-project
-- /verlauf. Distinct table from project_events so AuditService can union it
-- as a fifth source without polluting the verlauf SELECT.
--
-- One row per policy mutation (set or cleared, project-scoped or unit-scoped).
-- The actor is always a global_admin (the route gate enforces this).
--
-- scope_type / scope_id: ('project', project_id) or ('unit', partner_unit_id).
-- The FK columns (project_id, partner_unit_id) are nullable so a deleted
-- target row doesn't cascade-purge the audit history.
-- ============================================================================
CREATE TABLE paliad.policy_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
event_type text NOT NULL CHECK (event_type IN (
'approval_policy_set', 'approval_policy_cleared'
)),
scope_type text NOT NULL CHECK (scope_type IN ('project', 'unit')),
project_id uuid REFERENCES paliad.projects(id) ON DELETE SET NULL,
partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
-- Snapshot of the target's name at event time so a later cascade-set-null
-- doesn't lose the human label.
scope_name text NOT NULL,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
old_required_role text,
new_required_role text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT policy_audit_log_scope_xor CHECK (
(scope_type = 'project' AND project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(scope_type = 'unit' AND partner_unit_id IS NOT NULL AND project_id IS NULL) OR
(scope_type = 'project' AND project_id IS NULL AND partner_unit_id IS NULL) OR -- post-cascade
(scope_type = 'unit' AND partner_unit_id IS NULL AND project_id IS NULL) -- post-cascade
)
);
CREATE INDEX policy_audit_log_time_idx ON paliad.policy_audit_log (created_at DESC);
CREATE INDEX policy_audit_log_actor_idx ON paliad.policy_audit_log (actor_id, created_at DESC);
ALTER TABLE paliad.policy_audit_log ENABLE ROW LEVEL SECURITY;
-- Read access: any authenticated user (the audit-log page itself is
-- admin-gated at the route layer; this is defence-in-depth).
CREATE POLICY policy_audit_log_select ON paliad.policy_audit_log
FOR SELECT USING (auth.uid() IS NOT NULL);
-- Write access: only global_admin (defence-in-depth; service layer also
-- gates).
CREATE POLICY policy_audit_log_write ON paliad.policy_audit_log
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
COMMENT ON TABLE paliad.policy_audit_log IS
'Audit trail for paliad.approval_policies CRUD (t-paliad-154). '
'Surfaces on /admin/audit-log only — not on per-project /verlauf, per '
'design Q8 lock-in. Unioned by services.AuditService alongside '
'project_events / partner_unit_events / caldav_sync_log / reminder_log.';