-- 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 $$;