Der Security Advisor meldete 34 Warnungen; nach dem Festnageln des search_path sind es zehn. Von diesen zehn ist eine einzige ein echter Befund — aber die hätte man in den 34 nicht gesehen. search_path (34 Warnungen) Alle betroffenen Funktionen sind SECURITY INVOKER, laufen also mit den Rechten der aufrufenden Person; ein manipulierter Pfad bringt dort nichts zu holen. Die vier DEFINER-Funktionen setzen ihn längst. Festgenagelt wird es trotzdem, für den Tag, an dem jemand eine davon auf SECURITY DEFINER umstellt, weil eine Mutation an RLS vorbei schreiben muss — dann wäre es eine Rechteausweitung, und an den search_path denkt in dem Moment niemand. Als Schleife statt als Liste von 34 Signaturen: die würde beim nächsten Umbau veralten. Sie lässt Erweiterungen in Ruhe (pg_trgm legt show_trgm und show_limit ebenfalls in public ab) und prüft am Ende selbst nach. pg_temp steht ausdrücklich am Pfadende — ohne die Angabe durchsucht Postgres das temporäre Schema zuerst, und dort darf jede Sitzung anlegen, was sie will. Ausführungsrechte (8 Warnungen) Hier trennt sich der Befund vom Rauschen, und zwar durch Messen mit dem anon-Schlüssel gegen die laufende Datenbank: anon.rpc(is_hr_user) -> false anon.rpc(current_hr_user_id) -> null anon.rpc(apply_due_pending_changes) -> 0 Die ersten beiden bleiben offen, und das ist keine Nachlässigkeit: sie werden aus den RLS-Policies heraus aufgerufen, und ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle ausgewertet. Ohne EXECUTE scheitert jede Abfrage auf jeder Tabelle. Preisgegeben wird nichts — beide nehmen keine Argumente und beantworten nur eine Frage über die aufrufende Person selbst. Der dritte ist der Befund. apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen und Abwesenheiten an, ist SECURITY DEFINER, umgeht damit RLS — und war ohne Anmeldung aufrufbar. Der anon-Schlüssel steht im ausgelieferten Browser-Bündel. Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt werden, aber es ist ein Schreibpfad für Fremde und macht das Geheimnis der Cron-Route wirkungslos. Entzogen für anon und authenticated; die Route benutzt die service_role und läuft weiter. rls_auto_enable() stammt nicht aus diesen Migrationen und wird nirgends aufgerufen. Der Entzug ist risikolos und beantwortet die Frage, was sie tut, notfalls mit einer klaren Fehlermeldung. Zwei Warnungen bleiben bewusst stehen pg_trgm in public trägt die Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen. Ein Schemawechsel müsste Indizes und jeden search_path mitziehen — Risiko für eine Konvention, keine Rechteausweitung. „Leaked Password Protection" ist gegenstandslos: die Passwort-Anmeldung ist abgeschaltet, eine Anmeldung gegen die API antwortet mit email_provider_disabled. Es gibt kein Passwort, das kompromittiert sein könnte.
151 lines
6.4 KiB
PL/PgSQL
151 lines
6.4 KiB
PL/PgSQL
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
|
|
-- nachziehen.
|
|
--
|
|
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
|
|
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
|
|
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
|
|
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
|
|
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
|
|
-- Aufruf würde erst zur Laufzeit scheitern.
|
|
|
|
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
|
|
drop function if exists create_position(jsonb);
|
|
drop function if exists delete_position(jsonb);
|
|
drop function if exists delete_position(uuid);
|
|
drop function if exists staff_position_internally(jsonb);
|
|
drop function if exists apply_reorg(jsonb);
|
|
drop function if exists undo_reorg(jsonb);
|
|
drop function if exists undo_reorg(uuid);
|
|
|
|
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
|
|
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
|
|
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
|
|
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
|
|
alter table employee_history drop column if exists reorg_scenario_id;
|
|
alter table pending_org_changes drop column if exists reorg_scenario_id;
|
|
drop table if exists reorg_moves;
|
|
drop table if exists reorg_scenarios;
|
|
|
|
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
|
|
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
|
|
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
|
|
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
|
|
-- und Schliessen sind deshalb Operationen auf om_positions.
|
|
|
|
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
|
|
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
|
|
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
|
|
-- und daran denkt dann niemand.
|
|
create or replace function next_position_number()
|
|
returns text language sql stable
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
|
|
from om_positions
|
|
where position_number ~ '^6[0-9]{7}$';
|
|
$$;
|
|
|
|
comment on function next_position_number() is
|
|
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
|
|
|
|
create or replace function create_position(payload jsonb)
|
|
returns uuid language plpgsql
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
declare
|
|
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
|
|
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
|
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
|
|
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
|
v_job_id uuid;
|
|
v_position_id uuid;
|
|
v_unit_name text;
|
|
begin
|
|
perform require_hr_admin();
|
|
|
|
select name into v_unit_name from org_units where id = v_org_unit_id;
|
|
if v_unit_name is null then
|
|
raise exception 'Die Organisationseinheit existiert nicht.';
|
|
end if;
|
|
if v_job_title is null then
|
|
raise exception 'Es muss eine Tätigkeit angegeben werden.';
|
|
end if;
|
|
|
|
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
|
|
-- die in der Oberfläche nichts erklärt.
|
|
if v_is_chief and exists (
|
|
select 1 from om_positions
|
|
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
|
|
) then
|
|
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
|
end if;
|
|
|
|
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
|
|
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
|
|
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
|
|
if v_job_id is null then
|
|
insert into jobs (code, title)
|
|
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
|
|
returning id into v_job_id;
|
|
end if;
|
|
|
|
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
|
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
|
|
returning id into v_position_id;
|
|
|
|
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
|
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
|
|
v_job_title || ' (' || v_unit_name || ')',
|
|
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
|
|
|
|
return v_position_id;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function delete_position(payload jsonb)
|
|
returns void language plpgsql
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
declare
|
|
v_position_id uuid := (payload->>'position_id')::uuid;
|
|
v_label text;
|
|
v_hat_historie boolean;
|
|
begin
|
|
perform require_hr_admin();
|
|
|
|
select j.title || ' (' || u.name || ')' into v_label
|
|
from om_positions p
|
|
join jobs j on j.id = p.job_id
|
|
join org_units u on u.id = p.org_unit_id
|
|
where p.id = v_position_id;
|
|
if v_label is null then
|
|
raise exception 'Die Planstelle existiert nicht.';
|
|
end if;
|
|
|
|
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
|
|
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
|
|
end if;
|
|
|
|
select exists (select 1 from position_assignments where position_id = v_position_id)
|
|
into v_hat_historie;
|
|
|
|
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
|
|
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
|
|
-- Personalakte klafft eine Lücke.
|
|
if v_hat_historie then
|
|
update om_positions set valid_to = current_date where id = v_position_id;
|
|
else
|
|
delete from om_positions where id = v_position_id;
|
|
end if;
|
|
|
|
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
|
values (auth.uid(), current_actor_name(),
|
|
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
|
|
v_label, null);
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function next_position_number() to anon, authenticated, service_role;
|
|
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
|
|
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;
|