Files
alpenwerk-hr/supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql
Maximilian Stubhan e44f71a60d Stop offering positions that are already spoken for, and let hiring work again
Two reports, four defects, all of them in the way of ordinary use.

A position with a signed starter is not vacant. loadOpenPositions asked "is
anyone on it today?", so three positions whose new holders begin in
September and October were listed as open, labelled "vacant for 2 days".
That same list feeds the hire wizard, so it invited filling a position a
second time — discovered at the partial unique index, after the second
interview. Vacancy now means no assignment that still stands, including one
that has not started. An assignment that ended still frees the position.

Hiring was broken three times over, each fault hidden behind the previous
one:

  1. hire_employee cast to ::weekday[], a type that no longer exists — it
     was replaced by text plus a CHECK constraint and the function was never
     updated. apply_due_pending_changes had the same problem with
     ::relationship_type, which would have broken the nightly run.
     PL/pgSQL resolves types in embedded statements at execution time, so
     both functions were created without complaint and failed only in use.
  2. Fourteen functions called auth.uid(). The application connects as a
     role with no rights on the auth schema, so every write — hire,
     transfer, promote, exit, notes, positions — failed with "permission
     denied for schema auth". They now use app_current_user_id(), which is
     where #23 was heading anyway. Its own fallback also caught only
     "function missing" and now catches the privilege error too, so a call
     without session context returns null instead of raising.
  3. The audit line built a name as `payload->>'a' || ' ' || payload->>'b'`.
     `||` binds tighter than `->>`, so Postgres reads
     `payload ->> ('a' || ' ' || payload) ->> 'b'`. The ACL failure above
     had aborted analysis before the parser ever reached it.

And the wizard collected an email, showed it in the summary, and dropped it:
the server action's signature had no such field. employees.email is NOT
NULL, so every hire that got past the three faults above would have failed
there. It is now passed through and required in step one, rather than
refused by the database at the end of step four.

Verified against the live database, each rolled back: a hire now creates the
employee, the assignment, the history entry and an audit line reading "Probe
Einstellung"; open positions drop from 13 to 10, and the three that
disappear are exactly the ones with a starter.

Migrations rewrite the affected functions in place rather than restating
them — retyping 165 lines of working PL/pgSQL to change two words is the
larger risk. Each one asserts the result afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:10:17 +02:00

114 lines
3.9 KiB
PL/PgSQL

-- auth.uid() aus den Geschäftsfunktionen entfernen.
--
-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle
-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas
-- aufruft, scheitert mit „permission denied for schema auth".
--
-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern,
-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer
-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen
-- durfte.
--
-- app_current_user_id() liefert dasselbe und funktioniert auf jedem
-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db
-- setzt.
create or replace function app_uid_ersetzen(p_funktion text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
if position('auth.uid()' in v_def) = 0 then
raise notice '%(): bereits umgestellt.', p_funktion;
return;
end if;
execute replace(v_def, 'auth.uid()', 'app_current_user_id()');
raise notice '%(): umgestellt.', p_funktion;
end;
$$;
select app_uid_ersetzen('add_employee_dependent');
select app_uid_ersetzen('add_employee_note');
select app_uid_ersetzen('adjust_karenz_return');
select app_uid_ersetzen('complete_employee_note');
select app_uid_ersetzen('create_position');
select app_uid_ersetzen('delete_employee_dependent');
select app_uid_ersetzen('delete_position');
select app_uid_ersetzen('hire_employee');
select app_uid_ersetzen('promote_employee');
select app_uid_ersetzen('record_karenz_return');
select app_uid_ersetzen('rehire_employee');
select app_uid_ersetzen('start_karenz');
select app_uid_ersetzen('terminate_employee');
select app_uid_ersetzen('transfer_employee');
drop function app_uid_ersetzen(text);
-- ═══ Den Rückfall selbst absichern ═══════════════════════════════
-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt
-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema
-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt
-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne
-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet"
-- herauskommen und kein Fehler.
create or replace function app_current_user_id()
returns uuid
language plpgsql
stable
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
if v_id is not null then
return v_id;
end if;
begin
execute 'select auth.uid()' into v_id;
exception
when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then
v_id := null;
end;
return v_id;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare r record;
begin
for r in
select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f'
and p.proname <> 'app_current_user_id'
and pg_get_functiondef(p.oid) like '%auth.uid()%'
loop
raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname;
end loop;
-- Ohne Kontext muss die Kennung null sein und darf nicht werfen.
perform set_config('app.user_id', '', true);
if app_current_user_id() is not null then
raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.';
end if;
end;
$$;