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