Files
paliad/internal/db/migrations/054_approvals.up.sql
m b3401ec8ac feat(t-paliad-138): migration 054 — dual-control approvals schema
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.

Migration 054 adds:

1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
   English `project_teams_role_check` and the German-legacy
   `projekt_teams_role_check` (live-DB constraint name carried over
   from migration 018's pre-rename era).

2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
   ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
   > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
   the upcoming Go `levelOf()`.

3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
   required_role) — UNIQUE composite key gives at most 8 rows per
   project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
   for global_admin (defence-in-depth; service-role pool bypasses RLS,
   so the actual gate is service-layer).

4. `paliad.approval_requests` — operational pending workflow.
   pre_image jsonb captures revert state; payload echoes the diff;
   required_role snapshots the policy at request time. CHECK
   `decided_by != requested_by` is the second layer of self-approval
   block. RLS = same can_see_project predicate as deadlines /
   appointments — anyone with project visibility sees pending requests.

5. `approval_status` (default 'approved'), `pending_request_id`,
   `approved_by`, `approved_at` columns on both deadlines and
   appointments. `appointments.completed_at` (new) lands the
   appointment:complete lifecycle event.

6. Backfill: every existing deadline + appointment row marked
   approval_status='legacy'. Per Q11, no retroactive approval; the
   next mutation on a legacy row that hits an active policy follows
   the normal flow.

Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
2026-05-06 15:13:26 +02:00

238 lines
11 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-138: dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments.
--
-- Design: docs/design-approvals-2026-05-06.md (cronus, m-locked 2026-05-06).
--
-- Schema-only migration (commit 1 of 8). Adds the operational tables, the
-- strict-ladder helper, and the per-entity tracking columns. No Go code
-- reads these yet — paliad behaves identically until commit 2 wires the
-- ApprovalService into the mutation paths.
--
-- Sections:
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
-- 2. paliad.approval_role_level(text) — strict ladder helper.
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
-- 4. paliad.approval_requests — operational pending workflow.
-- 5. ALTER paliad.deadlines + paliad.appointments — approval columns
-- (approval_status, pending_request_id, approved_by, approved_at;
-- appointments also gains completed_at).
-- 6. Backfill: mark every existing row approval_status='legacy'.
--
-- ============================================================================
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
--
-- Live-DB finding (cronus, 2026-05-06): the existing constraint is named
-- `projekt_teams_role_check` (German leftover from migration 018, when the
-- table was `paliad.projekt_teams`; the table was renamed in 020 but the
-- constraint name was preserved by Postgres). Dropping by both names
-- defensively handles any future re-creation under the English name.
-- ============================================================================
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead', 'associate', 'pa', 'of_counsel',
'local_counsel', 'expert', 'observer',
'senior_pa'
));
-- ============================================================================
-- 2. paliad.approval_role_level — strict ladder over project_teams.role.
--
-- Mirrors internal/services/approval_levels.go:levelOf. A user with
-- project_teams.role R can approve any request whose required_role has level
-- <= level(R). Roles outside the approval ladder (local_counsel, expert,
-- observer, anything new) return 0 and are ineligible to approve at any
-- level. Default required_role on policies is 'associate' (level 3).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
'Strict-ladder level for approval gating (t-paliad-138). '
'Higher level always satisfies lower. Level 0 = ineligible. '
'Default policy required_role=associate (level 3) — eligible: lead, of_counsel, associate.';
-- ============================================================================
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
--
-- Up to 8 rows per project (deadline×4 + appointment×4). UNIQUE composite key
-- enforces this. No row = no approval needed for that event. Authoring is
-- gated to global_admin in the service layer; RLS lets project members read
-- their own project's policies (transparency: "do my edits need 4-eye?").
-- ============================================================================
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx
ON paliad.approval_policies (project_id);
ALTER TABLE paliad.approval_policies ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_policies_select ON paliad.approval_policies
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
-- Writes are restricted to global_admin in the application layer. The
-- service-role connection bypasses RLS, so these policies are
-- defence-in-depth for any future direct-DB access path.
CREATE POLICY approval_policies_write ON paliad.approval_policies
FOR ALL TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- ============================================================================
-- 4. paliad.approval_requests — operational pending workflow.
--
-- One row per submitted state-change that needs 4-eye sign-off. The entity
-- being changed is referenced by (entity_type, entity_id) — polymorphic
-- across deadlines / appointments, so no FK constraint on entity_id.
--
-- pre_image carries the field values needed to revert on rejection
-- (NULL for 'create' since there's nothing to revert to). payload echoes
-- the diff or new values that were written, for audit display.
--
-- required_role is a snapshot of the policy at request time — even if the
-- policy changes mid-flight, the request honours the level it was submitted
-- under.
--
-- decision_kind discriminates 'peer' (normal in-team sign-off) from
-- 'admin_override' (global_admin used the escape-hatch path). Verlauf
-- chronology renders these distinctly.
--
-- The CHECK on (decided_by != requested_by) is defence-in-depth alongside
-- the service-layer self-approval block.
-- ============================================================================
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
pre_image jsonb,
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT approval_requests_no_self_approval
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
-- Visible to anyone with project visibility (mirrors deadlines / appointments).
-- The approve/reject action is gated at the service layer, not here.
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- ============================================================================
-- 5. Approval columns on paliad.deadlines + paliad.appointments.
--
-- approval_status:
-- 'approved' (default for new + existing-after-backfill-clears),
-- 'pending' (an approval_request is in flight; pending_request_id set),
-- 'legacy' (predates 4-eye; backfilled in §6 below).
--
-- pending_request_id: FK to the in-flight approval_requests row. NULL when
-- approval_status != 'pending'. ON DELETE SET NULL keeps the entity row
-- intact if an approval_requests row is ever pruned.
--
-- approved_by / approved_at: set on transition to approval_status='approved'
-- after a 4-eye approval. NULL for 'legacy' rows and rows that never went
-- through 4-eye (no policy applied).
--
-- appointments.completed_at: new column for the appointment:complete
-- lifecycle event. Nullable; NULL means "not yet marked done".
-- ============================================================================
ALTER TABLE paliad.deadlines
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_pending_idx
ON paliad.deadlines (approval_status)
WHERE approval_status = 'pending';
ALTER TABLE paliad.appointments
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz,
ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_pending_idx
ON paliad.appointments (approval_status)
WHERE approval_status = 'pending';
-- ============================================================================
-- 6. Backfill: mark every existing row legacy.
--
-- Per design §6.5 / m's Q11 answer: existing pre-4-eye rows are read-clean;
-- they don't need retroactive approval. The next mutation on a legacy row
-- that hits an active policy (none exist on day 1) will trigger normal flow
-- and lift the row to 'approved' (or 'pending' until signed off).
--
-- created_by is already populated since migration 005. approved_by stays
-- NULL on legacy rows.
-- ============================================================================
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';