Files
paliad/internal/db/migrations/062_approval_policy_unit_defaults.up.sql
m e92c56b5f8 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.
2026-05-08 02:13:58 +02:00

286 lines
13 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Design: docs/design-approval-policy-ui-2026-05-07.md (hilbert, m-locked
-- 2026-05-07). Surfaces the dormant t-138 4-eye system by adding:
--
-- 1. partner_unit_id column on paliad.approval_policies — XOR with project_id
-- so a row applies to either one project or one partner unit (firm-wide
-- default for projects attached to that unit). Existing rows
-- (project_id IS NOT NULL) keep their meaning unchanged.
-- 2. 'none' sentinel value for required_role — explicit "no approval needed"
-- override at project-row level. Suppresses inherited defaults.
-- 3. paliad.approval_policy_effective() resolver — most-restrictive across
-- project row | ancestor rows | unit defaults. Project-specific row
-- wins outright (any value including 'none'). Otherwise MAX(level) across
-- 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).
--
-- Sections:
-- 1. Make project_id nullable + ADD partner_unit_id + XOR check.
-- 2. Replace UNIQUE composite with two partial unique indexes.
-- 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.
-- ============================================================================
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE paliad.approval_policies
ADD COLUMN partner_unit_id uuid
REFERENCES paliad.partner_units(id) ON DELETE CASCADE;
-- Exactly one of (project_id, partner_unit_id) must be set. NEVER both NULL,
-- NEVER both set. Defence against orphaned rows.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_scope_xor CHECK (
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(project_id IS NULL AND partner_unit_id IS NOT NULL)
);
-- ============================================================================
-- 2. Replace UNIQUE (project_id, ...) with two partial unique indexes.
--
-- The original UNIQUE composite assumed project_id NOT NULL. Now that
-- project_id is nullable, we split: one partial unique index per scope
-- (project rows | unit rows). Cells stay 1:1 within their scope.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
CREATE UNIQUE INDEX approval_policies_project_unique
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL;
CREATE UNIQUE INDEX approval_policies_unit_unique
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL;
CREATE INDEX approval_policies_unit_idx
ON paliad.approval_policies (partner_unit_id)
WHERE partner_unit_id IS NOT NULL;
-- ============================================================================
-- 3. 'none' sentinel value for required_role.
--
-- Project row with required_role='none' suppresses inherited defaults
-- explicitly (caller-side: LookupPolicy returns nil when the resolver
-- yields 'none'). Unit-default rows can also carry 'none' but are
-- structurally invisible to the MAX(level) computation since
-- approval_role_level('none')=0 (loses to any non-none).
--
-- approval_role_level('none') already returns 0 via the existing ELSE
-- branch in migration 059 §7. No function update needed.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
));
-- ============================================================================
-- 4. paliad.approval_policy_effective(project, entity_type, lifecycle).
--
-- Returns at most one row, or zero rows when no policy applies.
--
-- Resolution order:
-- Step 1: if a project-specific row exists for (project, entity, lifecycle),
-- return it outright. Any value (including 'none') wins.
-- Step 2: else MAX(approval_role_level) across:
-- - ancestor project rows on this project's ltree path
-- - unit-default rows for partner units attached to this project
-- Tied levels: 'ancestor' beats 'unit_default' alphabetically for
-- stable attribution.
-- Step 3: else (no candidates) — return zero rows. Caller treats as
-- "no policy applies".
--
-- Returned columns:
-- required_role text — one of partner|of_counsel|associate|senior_pa|pa|none
-- source text — 'project' | 'ancestor' | 'unit_default'
-- source_id uuid — project_id for project/ancestor, partner_unit_id for unit_default
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
-- Step 1: project-specific row wins outright.
RETURN QUERY
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle;
IF FOUND THEN
RETURN;
END IF;
-- Step 2: MAX(level) across ancestor rows + unit defaults attached to project.
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
)
SELECT a.required_role, a.src, a.sid
FROM (
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
) AS a
ORDER BY a.lvl DESC, a.src ASC
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-154). '
'Project-specific row wins outright; else MAX(level) across ancestor '
'rows on the project path and unit-default rows for attached partner '
'units; else no row. Caller treats 0 rows or required_role=''none'' as '
'"no policy applies".';
-- ============================================================================
-- 5. Seed conservative defaults for every existing partner_unit.
--
-- 8 rows per unit: deadline+appointment × create/update/delete=associate,
-- complete=none. Marking-as-done is low-risk; planning operations need 4-eye.
--
-- ON CONFLICT DO NOTHING on (partner_unit_id, entity_type, lifecycle_event)
-- via the partial unique index so re-running the migration (or topping up
-- after a partner_unit is added) is a no-op for existing rows.
--
-- Safe on a DB with 0 partner_units — the SELECT returns no rows.
-- ============================================================================
INSERT INTO paliad.approval_policies (
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
FROM paliad.partner_units pu
CROSS JOIN (
VALUES
('deadline', 'create', 'associate'),
('deadline', 'update', 'associate'),
('deadline', 'delete', 'associate'),
('deadline', 'complete', 'none'),
('appointment', 'create', 'associate'),
('appointment', 'update', 'associate'),
('appointment', 'delete', 'associate'),
('appointment', 'complete', 'none')
) AS t(entity_type, lifecycle_event, 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.';