diff --git a/db/migrations/20260924140000_ohne_auth_uid.sql b/db/migrations/20260924140000_ohne_auth_uid.sql deleted file mode 100644 index b7d7a5e..0000000 --- a/db/migrations/20260924140000_ohne_auth_uid.sql +++ /dev/null @@ -1,402 +0,0 @@ --- 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 -$$;