diff --git a/db/migrations/20260924140000_ohne_auth_uid.sql b/db/migrations/20260924140000_ohne_auth_uid.sql new file mode 100644 index 0000000..b7d7a5e --- /dev/null +++ b/db/migrations/20260924140000_ohne_auth_uid.sql @@ -0,0 +1,402 @@ +-- Acht Funktionen riefen auth.uid() — und scheiterten damit vollständig. +-- +-- ═══ Der Nachweis ══════════════════════════════════════════════════ +-- +-- Die Attrappe des Supabase-Schemas (deploy/db-init/01-auth-attrappe.sql) +-- gibt der Anwendungsrolle **mit Absicht** kein `usage` auf `auth`: +-- „fiele je wieder ein Bezug auf dieses Schema in den laufenden Betrieb, soll +-- er scheitern und nicht stillschweigend funktionieren." Sie scheitern. +-- +-- Auf dem Server nachgemessen, nicht vermutet: +-- +-- select has_schema_privilege('alpenwerk_app', 'auth', 'usage'); -- f +-- set local role alpenwerk_app; select auth.uid(); +-- -- ERROR: permission denied for schema auth +-- +-- Keine der acht ist `security definer`, sie laufen also mit den Rechten der +-- Anwendungsrolle. Betroffen war damit je eine vollständige Operation: +-- +-- transfer_employee — Versetzung +-- create_position — Planstelle anlegen +-- delete_position — Planstelle löschen +-- add_employee_dependent — Angehörige hinzufügen +-- delete_employee_dependent — Angehörige entfernen +-- add_employee_note — HR-Notiz anlegen +-- complete_employee_note — HR-Notiz erledigen +-- adjust_karenz_return — Rückkehrdatum verschieben +-- +-- ═══ Warum es so lange unbemerkt blieb ═════════════════════════════ +-- +-- Der Umstieg von auth.uid() auf app_current_user_id() geschah im August +-- (20260805110000, 20260805140000) und danach Funktion für Funktion, sobald +-- eine ohnehin angefasst wurde. Diese acht wurden seither nicht mehr +-- angefasst. Nichts prüfte den Rest: die Migrationen laufen durch, die Tests +-- kennen keine Datenbank, und wer die Oberfläche bedient, bekommt die +-- Meldung erst im Moment des Speicherns. +-- +-- Deshalb steht am Ende eine Prüfung, die **das ganze Schema** durchgeht und +-- nicht nur die acht: sie schlägt an, sobald irgendeine Funktion in `public` +-- das Schema `auth` wieder anfasst. +-- +-- Geändert wird an jeder Funktion nur zweierlei: auth.uid() wird zu +-- app_current_user_id(), und der search_path steht fest. Kein Verhalten +-- sonst — die Körper sind unverändert aus ihren letzten gültigen Fassungen +-- übernommen. + +-- ── adjust_karenz_return (Vorlage: 20260714120600_data_integrity_guards.sql) ───────────── +create or replace function adjust_karenz_return(payload jsonb) +returns void language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_new_return_date date := (payload->>'new_return_date')::date; + v_karenz_start date; + v_name text; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name, karenz_start_date into v_name, v_karenz_start from employees where id = v_employee_id; + + if v_karenz_start is not null and v_new_return_date <= v_karenz_start then + raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start; + end if; + + update employees set karenz_return_date = v_new_return_date where id = v_employee_id; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, current_date, 'Karenz', + 'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') || + case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date')); +end; +$$; + +grant execute on function public.adjust_karenz_return(payload jsonb) to alpenwerk_app; + +-- ── add_employee_dependent (Vorlage: 20260718160000_dependents_effective_dating.sql) ───────────── +create or replace function add_employee_dependent(payload jsonb) +returns void language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_employee_name text; + v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); + v_dep_name text := (payload->>'first_name') || ' ' || (payload->>'last_name'); +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id; + if not found then + raise exception 'Mitarbeiter:in nicht gefunden.'; + end if; + + if v_effective_date <= current_date then + insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date) + values ( + v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship', + nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date + ); + else + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'dependent_add', v_effective_date, payload); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values ( + v_employee_id, v_effective_date, 'Stammdatenänderung', + 'Angehörige:r hinzugefügt: ' || v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date + ); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values ( + app_current_user_id(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id, + v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date + ); +end; +$$; + +grant execute on function public.add_employee_dependent(payload jsonb) to alpenwerk_app; + +-- ── delete_employee_dependent (Vorlage: 20260718160000_dependents_effective_dating.sql) ───────────── +create or replace function delete_employee_dependent(payload jsonb) +returns void language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_dep employee_dependents%rowtype; + v_employee_name text; + v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); +begin + perform require_hr_admin(); + select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid; + if not found then + raise exception 'Angehörige:r nicht gefunden.'; + end if; + select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id; + + if v_effective_date <= current_date then + delete from employee_dependents where id = v_dep.id; + else + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_dep.employee_id, 'dependent_remove', v_effective_date, jsonb_build_object('dependent_id', v_dep.id)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values ( + v_dep.employee_id, v_effective_date, 'Stammdatenänderung', + 'Angehörige:r entfernt: ' || v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date + ); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values ( + app_current_user_id(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id, + v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date + ); +end; +$$; + +grant execute on function public.delete_employee_dependent(payload jsonb) to alpenwerk_app; + +-- ── add_employee_note (Vorlage: 20260719120000_employee_notes.sql) ───────────── +create or replace function add_employee_note(payload jsonb) +returns uuid language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_id uuid; + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_employee_name text; + v_category text := coalesce(nullif(payload->>'category', ''), 'Allgemein'); + v_note_text text := payload->>'note_text'; + v_due_date date := nullif(payload->>'due_date', '')::date; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id; + if not found then + raise exception 'Mitarbeiter:in nicht gefunden.'; + end if; + if coalesce(btrim(v_note_text), '') = '' then + raise exception 'Notiztext darf nicht leer sein.'; + end if; + + insert into employee_notes (employee_id, author_user_id, author_name, category, note_text, due_date) + values (v_employee_id, app_current_user_id(), current_actor_name(), v_category, v_note_text, v_due_date) + returning id into v_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values ( + app_current_user_id(), current_actor_name(), 'HR-Notiz hinzugefügt', v_employee_name, v_employee_id, + '[' || v_category || '] ' || left(v_note_text, 200) || + case when v_due_date is not null then ', Wiedervorlage am ' || v_due_date else '' end + ); + + return v_id; +end; +$$; + +grant execute on function public.add_employee_note(payload jsonb) to alpenwerk_app; + +-- ── complete_employee_note (Vorlage: 20260719120000_employee_notes.sql) ───────────── +create or replace function complete_employee_note(payload jsonb) +returns void language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_note employee_notes%rowtype; + v_employee_name text; +begin + perform require_hr_admin(); + select * into v_note from employee_notes where id = (payload->>'note_id')::uuid; + if not found then + raise exception 'Notiz nicht gefunden.'; + end if; + if v_note.done then + raise exception 'Notiz ist bereits erledigt.'; + end if; + select first_name || ' ' || last_name into v_employee_name from employees where id = v_note.employee_id; + + update employee_notes set done = true, done_at = now(), done_by = app_current_user_id() where id = v_note.id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values ( + app_current_user_id(), current_actor_name(), 'HR-Notiz erledigt', v_employee_name, v_note.employee_id, + '[' || v_note.category || '] ' || left(v_note.note_text, 200) + ); +end; +$$; + +grant execute on function public.complete_employee_note(payload jsonb) to alpenwerk_app; + +-- ── transfer_employee (Vorlage: 20260727120200_om_cutover.sql) ───────────── +create or replace function transfer_employee(payload jsonb) +returns void language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_target_position uuid := (payload->>'target_position_id')::uuid; + v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date); + v_name text; + v_besetzt uuid; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_target_position and pa.valid_to is null; + if v_besetzt is not null and v_besetzt <> v_employee_id then + raise exception 'Die Zielplanstelle ist bereits besetzt.'; + end if; + + if v_effective <= current_date then + update position_assignments set valid_to = v_effective + where employee_id = v_employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values (v_target_position, v_employee_id, v_effective); + update employees set job_title = + (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position) + where id = v_employee_id; + else + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'transfer', v_effective, + jsonb_build_object('target_position_id', v_target_position)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' || + (select position_number from om_positions where id = v_target_position)); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective); +end; +$$; + +grant execute on function public.transfer_employee(payload jsonb) to alpenwerk_app; + +-- ── create_position (Vorlage: 20260727130000_om_cleanup_and_positions.sql) ───────────── +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 (app_current_user_id(), 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; +$$; + +grant execute on function public.create_position(payload jsonb) to alpenwerk_app; + +-- ── delete_position (Vorlage: 20260727130000_om_cleanup_and_positions.sql) ───────────── +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 (app_current_user_id(), current_actor_name(), + case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end, + v_label, null); +end; +$$; + +grant execute on function public.delete_position(payload jsonb) to alpenwerk_app; + +-- Selbstprüfung. +do $$ +declare + v_rest text; +begin + select string_agg(p.proname, ', ' order by p.proname) into v_rest + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + and p.prokind = 'f' + and pg_get_functiondef(p.oid) like '%auth.uid()%'; + + if v_rest is not null then + raise exception 'Diese Funktionen rufen weiterhin auth.uid(): %', v_rest; + end if; + + -- Und das Recht bleibt entzogen: ginge es je wieder auf, fiele der + -- nächste Rückfall nicht mehr auf. + if has_schema_privilege('alpenwerk_app', 'auth', 'usage') then + raise exception 'Die Anwendungsrolle darf das Schema auth benutzen — der Schutz ist weg.'; + end if; +end +$$;