Implements docs/design-kanzlai-integration.md §8 Phase A. Schema (paliad.*): - users (extends auth.users) with office, practice_group, role - akten with visibility columns: owning_office, collaborators uuid[], firm_wide_visible (per design §2) - parteien, fristen, termine, dokumente, akten_events, notizen (polymorphic notes; notizen_exactly_one_parent CHECK) - proceeding_types, deadline_rules, holidays (reference data) - 4 feedback tables re-namespaced from public.* into paliad.* (handler swap to direct DB is a follow-up; old public tables stay intact for now and continue serving via PostgREST) Visibility (paliad.can_see_akte): - single SQL function, used by every RLS policy - predicate: firm_wide_visible OR owning_office matches user's office OR auth.uid() ∈ collaborators OR user is admin - mirrored at app layer in Phase B (defense in depth) RLS (real, not permissive): - akten: visibility predicate; insert restricted to own office or admin; delete restricted to partners + admins - parteien/fristen/dokumente/akten_events: inherit via can_see_akte(akte_id) - termine: personal (akte_id NULL) visible only to creator; Akte-linked follow visibility predicate - notizen: paliad.notiz_is_visible() resolves polymorphic parent - reference tables: SELECT for any authenticated user - users: SELECT all; UPDATE/INSERT only self - feedback tables: INSERT for any authenticated user (write-only) Seed data (ported from KanzlAI seed_upc_timeline.sql): - 7 proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL) - 40 deadline_rules (32 UPC + 4 ZPO + 4 cross-type appeal spawns) including conditional logic: Reply rule code (RoP.029b → 029a) and Rejoinder duration (1mo → 2mo) flip when CCR active - 55 holidays (DE federal 2026/2027 + UPC summer 2026 + UPC winter 26/27) Indexes per audit §3.3 + visibility-predicate hot paths: - akten: (status, owning_office), (owning_office), partial on firm_wide_visible, GIN on collaborators - fristen: (status, due_date), (akte_id) - termine: (start_at), (akte_id) - akten_events: (akte_id, created_at DESC) - notizen: 4 partial indexes per parent type - users: (office), (role) Migration tooling: - golang-migrate/migrate/v4 with embed.FS source - Migrations live in internal/db/migrations/ (Go embed can't reach outside the package; this is the conventional Go layout for embedded migrations) - Applied at server startup before HTTP listener binds - DATABASE_URL is optional today (existing knowledge tools work without DB); becomes required once Phase B services land - Mock Supabase auth schema for local testing in internal/db/migrations/_dev/mock_supabase_auth.sql (excluded from embed pattern by the underscore prefix) Other changes: - Dockerfile: bump golang to 1.24, copy go.sum (audit §2.9), rename binary patholo → paliad - docker-compose.yml: add DATABASE_URL passthrough - README.md: rewritten to reflect Paliad brand + Phase A migration system Verified locally: - 11 migrations applied cleanly against postgres:16-alpine - RLS enabled on all 15 paliad.* tables (verified via pg_class.relrowsecurity) - Visibility predicate verified with 4-case scenario: - Alice (Munich associate): sees Munich + firm-wide + collab-on (t f t t) - Bob (Düsseldorf associate): sees Düsseldorf + firm-wide + collab-on (f t t t) - Carol (Munich partner): sees Munich + firm-wide only (t f t f) - Anonymous: sees firm-wide only (f f t f) - migrate down + re-up cycle clean (initial 007 down had ordering bug, fixed: drop policies before referenced function) - Existing endpoints (/, /login) return 302 + 200 — no regressions
170 lines
7.0 KiB
PL/PgSQL
170 lines
7.0 KiB
PL/PgSQL
-- Phase A: Row-Level Security policies (design §2).
|
|
--
|
|
-- Posture:
|
|
-- - users : authenticated users can SELECT everyone; UPDATE only self; no public INSERT (Phase D handler inserts via service role)
|
|
-- - akten : office-scoped visibility via paliad.can_see_akte()
|
|
-- - akten children : inherit visibility via paliad.can_see_akte(akte_id)
|
|
-- - reference tables : any authenticated user can SELECT
|
|
-- - notizen : visibility follows whichever parent is set
|
|
--
|
|
-- Every table has RLS enabled. No permissive `USING (true)` on user data.
|
|
|
|
-- ============================================================================
|
|
-- users
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.users ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY users_select_all ON paliad.users
|
|
FOR SELECT TO authenticated
|
|
USING (true);
|
|
|
|
CREATE POLICY users_update_self ON paliad.users
|
|
FOR UPDATE TO authenticated
|
|
USING (id = auth.uid())
|
|
WITH CHECK (id = auth.uid());
|
|
|
|
CREATE POLICY users_insert_self ON paliad.users
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (id = auth.uid());
|
|
|
|
-- ============================================================================
|
|
-- akten
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.akten ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY akten_select ON paliad.akten
|
|
FOR SELECT TO authenticated
|
|
USING (paliad.can_see_akte(id));
|
|
|
|
-- Create: user can only create Akten in their own office; admins anywhere.
|
|
CREATE POLICY akten_insert ON paliad.akten
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
|
|
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'
|
|
);
|
|
|
|
-- Update: any user with visibility can update.
|
|
CREATE POLICY akten_update ON paliad.akten
|
|
FOR UPDATE TO authenticated
|
|
USING (paliad.can_see_akte(id))
|
|
WITH CHECK (paliad.can_see_akte(id));
|
|
|
|
-- Delete: only partners and admins. (Role check redundant-safe.)
|
|
CREATE POLICY akten_delete ON paliad.akten
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
paliad.can_see_akte(id)
|
|
AND (SELECT role FROM paliad.users WHERE id = auth.uid()) IN ('partner', 'admin')
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- parteien / fristen / termine / dokumente / akten_events
|
|
-- Visibility inherited from the parent Akte.
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.parteien ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.fristen ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.termine ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.dokumente ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.akten_events ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY parteien_all ON paliad.parteien
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_akte(akte_id))
|
|
WITH CHECK (paliad.can_see_akte(akte_id));
|
|
|
|
CREATE POLICY fristen_all ON paliad.fristen
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_akte(akte_id))
|
|
WITH CHECK (paliad.can_see_akte(akte_id));
|
|
|
|
-- termine.akte_id is nullable (personal appointments not tied to an Akte).
|
|
-- Personal appointments visible only to the creator; Akte-linked ones follow
|
|
-- the visibility predicate.
|
|
CREATE POLICY termine_select ON paliad.termine
|
|
FOR SELECT TO authenticated
|
|
USING (
|
|
(akte_id IS NULL AND created_by = auth.uid())
|
|
OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))
|
|
);
|
|
CREATE POLICY termine_insert ON paliad.termine
|
|
FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
(akte_id IS NULL AND created_by = auth.uid())
|
|
OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))
|
|
);
|
|
CREATE POLICY termine_update ON paliad.termine
|
|
FOR UPDATE TO authenticated
|
|
USING (
|
|
(akte_id IS NULL AND created_by = auth.uid())
|
|
OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))
|
|
)
|
|
WITH CHECK (
|
|
(akte_id IS NULL AND created_by = auth.uid())
|
|
OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))
|
|
);
|
|
CREATE POLICY termine_delete ON paliad.termine
|
|
FOR DELETE TO authenticated
|
|
USING (
|
|
(akte_id IS NULL AND created_by = auth.uid())
|
|
OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))
|
|
);
|
|
|
|
CREATE POLICY dokumente_all ON paliad.dokumente
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_akte(akte_id))
|
|
WITH CHECK (paliad.can_see_akte(akte_id));
|
|
|
|
CREATE POLICY akten_events_all ON paliad.akten_events
|
|
FOR ALL TO authenticated
|
|
USING (paliad.can_see_akte(akte_id))
|
|
WITH CHECK (paliad.can_see_akte(akte_id));
|
|
|
|
-- ============================================================================
|
|
-- notizen — visibility follows whichever parent FK is set
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.notizen ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Helper: notiz is visible iff its parent is visible.
|
|
-- For frist/termin/akten_event, we resolve to the underlying Akte.
|
|
CREATE OR REPLACE FUNCTION paliad.notiz_is_visible(
|
|
_akte_id uuid,
|
|
_frist_id uuid,
|
|
_termin_id uuid,
|
|
_akten_event_id uuid
|
|
) RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = paliad, public
|
|
AS $$
|
|
SELECT CASE
|
|
WHEN _akte_id IS NOT NULL THEN paliad.can_see_akte(_akte_id)
|
|
WHEN _frist_id IS NOT NULL THEN paliad.can_see_akte((SELECT akte_id FROM paliad.fristen WHERE id = _frist_id))
|
|
WHEN _termin_id IS NOT NULL THEN
|
|
CASE
|
|
WHEN (SELECT akte_id FROM paliad.termine WHERE id = _termin_id) IS NULL
|
|
THEN (SELECT created_by FROM paliad.termine WHERE id = _termin_id) = auth.uid()
|
|
ELSE paliad.can_see_akte((SELECT akte_id FROM paliad.termine WHERE id = _termin_id))
|
|
END
|
|
WHEN _akten_event_id IS NOT NULL THEN paliad.can_see_akte((SELECT akte_id FROM paliad.akten_events WHERE id = _akten_event_id))
|
|
ELSE false
|
|
END;
|
|
$$;
|
|
|
|
CREATE POLICY notizen_all ON paliad.notizen
|
|
FOR ALL TO authenticated
|
|
USING (paliad.notiz_is_visible(akte_id, frist_id, termin_id, akten_event_id))
|
|
WITH CHECK (paliad.notiz_is_visible(akte_id, frist_id, termin_id, akten_event_id));
|
|
|
|
-- ============================================================================
|
|
-- reference tables — any authenticated user can SELECT; nobody writes via API
|
|
-- ============================================================================
|
|
ALTER TABLE paliad.proceeding_types ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.deadline_rules ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE paliad.holidays ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY proceeding_types_select ON paliad.proceeding_types FOR SELECT TO authenticated USING (true);
|
|
CREATE POLICY deadline_rules_select ON paliad.deadline_rules FOR SELECT TO authenticated USING (true);
|
|
CREATE POLICY holidays_select ON paliad.holidays FOR SELECT TO authenticated USING (true);
|