diff --git a/db/migrations/20260924220000_reporting_lines_ohne_zeilenschutz.sql b/db/migrations/20260924220000_reporting_lines_ohne_zeilenschutz.sql new file mode 100644 index 0000000..9b1046f --- /dev/null +++ b/db/migrations/20260924220000_reporting_lines_ohne_zeilenschutz.sql @@ -0,0 +1,167 @@ +-- om_reporting_lines prüft die Rechte einmal, statt sie je Zeile prüfen zu lassen. +-- +-- ═══ Gemessen, nicht geschätzt ═════════════════════════════════════ +-- +-- Die Personalakte brauchte beim Öffnen 1,3 bis 1,5 Sekunden; davon lagen +-- über 1,1 s in dieser Funktion. Als `postgres` — also am Zeilenschutz vorbei +-- — kostet dieselbe Funktion 60 ms. +-- +-- als postgres 60 ms +-- als alpenwerk_app, vor 20260924200000 1457 ms +-- als alpenwerk_app, danach 1153 ms +-- +-- Die vorige Migration hat die Policies so gefasst, dass `is_hr_user()` je +-- Abfrage einmal läuft statt je Zeile. Das half, aber nur um ein Fünftel — +-- also war die Prüfung nicht der Hauptposten. +-- +-- Die Teile der Funktion sind einzeln billig, unter Zeilenschutz gemessen: +-- +-- laufende Besetzungen (784 Zeilen) 12 ms +-- Vorfahrenkette (379 Zeilen) 7 ms +-- Leitungen je Einheit (74 Zeilen) 9 ms +-- +-- Zusammen 28 ms — die Funktion daraus 1153. Die Kosten entstehen also nicht +-- beim Lesen, sondern beim Zusammensetzen: unter Zeilenschutz wird aus jeder +-- Tabellenreferenz eine Unterabfrage mit Sicherheitsschranke, und der Planer +-- verliert die Freiheit, die Zwischenmengen **einmal** zu berechnen. Die +-- beiden Unterabfragen am Ende laufen dann für jede der 784 Zeilen erneut. +-- +-- ═══ Was sich ändert und was nicht ═════════════════════════════════ +-- +-- Die Funktion läuft jetzt als `security definer` und beginnt mit +-- `require_hr_admin()`. Das ist **dieselbe Bedingung**, die in den Policies +-- aller vier gelesenen Tabellen steht — `require_hr_admin()` ist wörtlich +-- „if not is_hr_user() then raise". Sie wird nur einmal gestellt statt +-- hunderttausendfach. +-- +-- Wer Daten bekommt, ändert sich nicht: aktive HR-Person ja, alle anderen +-- nein. Der Unterschied ist, dass eine nicht berechtigte Anfrage jetzt eine +-- Meldung bekommt statt einer leeren Antwort — so verhält sich jede andere +-- Funktion dieser Anwendung auch. +-- +-- ═══ Der Preis, der dafür anfällt ══════════════════════════════════ +-- +-- Diese Funktion liest die vier Tabellen künftig am Zeilenschutz vorbei. Käme +-- je eine feinere Rechteordnung — Unterrollen, HR-Personen mit +-- eingeschränktem Blick —, dann müsste sie **eigens** nachgezogen werden; die +-- Policies tun es für sie nicht mehr. Mit dem Eigentümer besprochen und +-- entschieden: solche Rollen sind nicht vorgesehen. +-- +-- Der Körper ist unverändert aus 20260727120100 übernommen — die einzige +-- textliche Fassung, die es je gab. Geändert sind nur Sprache (für die +-- Prüfung braucht es eine Anweisung vor der Abfrage), `security definer`, +-- der feste `search_path` und eben diese erste Zeile. + +create or replace function om_reporting_lines(p_as_of date default current_date) +returns table ( + employee_id uuid, + position_id uuid, + org_unit_id uuid, + is_chief boolean, + formal_manager_id uuid, + acting_manager_id uuid +) +language plpgsql +stable +security definer +set search_path to 'public', 'pg_temp' +as $$ +begin + perform require_hr_admin(); + + return query + with recursive + -- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag + -- gültig sein. + holder as ( + select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief + from position_assignments pa + join om_positions p on p.id = pa.position_id + where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of) + and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of) + ), + -- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist + -- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite. + chief as ( + select h.org_unit_id, h.employee_id, + (e.karenz_start_date is not null + and e.karenz_start_date <= p_as_of + and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent + from holder h + join employees e on e.id = h.employee_id + where h.is_chief + ), + -- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. + ancestry as ( + select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth + from org_units u + union all + select a.unit_id, p.id, p.parent_id, a.depth + 1 + from ancestry a + join org_units p on p.id = a.parent_id + ), + -- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete, + -- sonst die eigene. + base as ( + select h.employee_id, h.position_id, h.org_unit_id, h.is_chief, + case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id + from holder h + join org_units u on u.id = h.org_unit_id + ) + select + b.employee_id, + b.position_id, + b.org_unit_id, + b.is_chief, + -- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit. + (select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id, + -- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb, + -- die Ausgangseinheit eingeschlossen. + ( + select c.employee_id + from ancestry a + join chief c on c.org_unit_id = a.ancestor_id + where a.unit_id = b.base_unit_id + and not c.absent + and c.employee_id <> b.employee_id + order by a.depth + limit 1 + ) as acting_manager_id + from base b; +end; +$$; + +comment on function om_reporting_lines(date) is + 'Abgeleitete Berichtslinie zum Stichtag. Security definer mit require_hr_admin() in der ersten Zeile: dieselbe Bedingung wie in den Policies der gelesenen Tabellen, einmal geprüft statt je Zeile.'; + +grant execute on function public.om_reporting_lines(p_as_of date) to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_def text := pg_get_functiondef('public.om_reporting_lines(date)'::regprocedure); +begin + -- Ohne die Prüfung in der ersten Zeile wäre aus der Funktion ein Loch im + -- Zeilenschutz geworden: security definer liest an den Policies vorbei. + if v_def not like '%require_hr_admin%' then + raise exception 'om_reporting_lines prueft die Rechte nicht — security definer waere hier ein Loch.'; + end if; + if position('SECURITY DEFINER' in upper(v_def)) = 0 then + raise exception 'om_reporting_lines ist nicht security definer — die Umstellung hat nicht gegriffen.'; + end if; + if position('SET search_path' in v_def) = 0 then + raise exception 'om_reporting_lines hat keinen festen search_path — bei security definer ist das Pflicht.'; + end if; + + -- Und das Ergebnis bleibt, was es war: eine Zeile je laufender Besetzung. + if (select count(*) from om_reporting_lines(current_date)) + <> (select count(*) + from position_assignments pa + join om_positions p on p.id = pa.position_id + where pa.valid_from <= current_date and (pa.valid_to is null or pa.valid_to > current_date) + and p.valid_from <= current_date and (p.valid_to is null or p.valid_to > current_date)) then + raise exception 'om_reporting_lines liefert nicht mehr eine Zeile je laufender Besetzung.'; + end if; +end +$$;