Files
alpenwerk-hr/db/migrations/20260924220000_reporting_lines_ohne_zeilenschutz.sql
Andrei Laas 520242e3c3
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Die Selbstpruefung ruft die Funktion nicht mehr auf
Die Migration ist an ihrer eigenen Pruefung gescheitert:

  FEHLGESCHLAGEN 20260924220000_reporting_lines_ohne_zeilenschutz.sql
  Nicht berechtigt: nur aktive HR-Benutzer:innen duerfen diese Aktion
  ausfuehren.

Die Pruefung verglich die Zeilenzahl der neuen Funktion mit der Zahl der
laufenden Besetzungen -- und rief sie dafuer auf. Die Funktion verlangt
seit dieser Migration HR-Rechte, die Migration selbst laeuft aber als
Administrator ohne app.user_id. require_hr_admin() hat sie also zu Recht
abgewiesen.

Nichts wurde eingespielt, die Transaktion ist vollstaendig
zurueckgerollt.

Jetzt wird der Koerper gelesen statt ausgefuehrt: geprueft wird, dass
require_hr_admin, security definer und der search_path stehen und dass
die Bestandteile der Abfrage vollzaehlig uebertragen sind. Eine
Selbstpruefung darf nichts aufrufen, was einen angemeldeten
Anwendungsbenutzer voraussetzt.
2026-09-24 12:33:19 +02:00

173 lines
7.2 KiB
PL/PgSQL

-- 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);
v_teil text;
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;
-- Der Körper wird **gelesen, nicht ausgeführt**. Ein Aufruf hier wäre der
-- erste Fehlversuch dieser Migration gewesen: sie läuft als Administrator
-- ohne `app.user_id`, und require_hr_admin() weist sie damit zu Recht ab.
-- Eine Selbstprüfung darf nichts aufrufen, was einen angemeldeten
-- Anwendungsbenutzer voraussetzt.
foreach v_teil in array array[
'holder as', 'chief as', 'ancestry as', 'base as',
'formal_manager_id', 'acting_manager_id', 'order by a.depth'
] loop
if position(v_teil in v_def) = 0 then
raise exception 'Im Koerper fehlt „%" — beim Uebertragen ist etwas verlorengegangen.', v_teil;
end if;
end loop;
end
$$;