om_reporting_lines prueft die Rechte einmal statt je Zeile
Die Akte brauchte 1,3 bis 1,5 s, davon ueber 1,1 s in dieser Funktion. Als postgres -- 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 fasste die Policies so, dass is_hr_user() je Abfrage einmal laeuft. Das half um ein Fuenftel -- die Pruefung war also nicht der Hauptposten. Die Teile der Funktion sind einzeln billig, unter Zeilenschutz gemessen: Besetzungen 12 ms, Vorfahrenkette 7 ms, Leitungen 9 ms. Zusammen 28 ms, die Funktion daraus 1153. Die Kosten entstehen 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 fuer jede der 784 Zeilen erneut. Jetzt security definer mit require_hr_admin() in der ersten Zeile. Das ist dieselbe Bedingung, die in den Policies aller vier gelesenen Tabellen steht -- require_hr_admin() ist woertlich "if not is_hr_user() then raise". Sie wird einmal gestellt statt hunderttausendfach. Wer Daten bekommt, aendert sich nicht; eine unberechtigte Anfrage bekommt jetzt eine Meldung statt einer leeren Antwort. Der Preis: diese Funktion liest die vier Tabellen am Zeilenschutz vorbei. Kaeme je eine feinere Rechteordnung, muesste sie eigens nachgezogen werden. Mit dem Eigentuemer besprochen -- solche Rollen sind nicht vorgesehen. Der Koerper ist unveraendert aus 20260727120100, der einzigen textlichen Fassung. Geaendert sind Sprache, security definer, search_path und die erste Zeile.
This commit is contained in:
@@ -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
|
||||
$$;
|
||||
Reference in New Issue
Block a user