Acht Funktionen riefen auth.uid() -- und scheiterten damit vollstaendig
Die Attrappe des Supabase-Schemas 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." Er ist gefallen, und 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 je eine vollstaendige Operation:
Versetzung, Planstelle anlegen, Planstelle loeschen, Angehoerige
hinzufuegen und entfernen, HR-Notiz anlegen und erledigen, Rueckkehrdatum
verschieben.
Der Umstieg auf app_current_user_id() geschah im August und danach
Funktion fuer Funktion, sobald eine ohnehin angefasst wurde. Diese acht
wurden seither nicht mehr angefasst, und nichts prueft den Rest: die
Migrationen laufen durch, die Tests kennen keine Datenbank, und wer die
Oberflaeche bedient, bekommt die Meldung erst beim Speichern.
Geaendert ist nur zweierlei je Funktion -- auth.uid() wird
app_current_user_id(), und der search_path steht fest. Die Koerper sind
unveraendert aus ihren letzten gueltigen Fassungen uebernommen.
Die Selbstpruefung geht das ganze Schema durch statt nur die acht: sie
schlaegt an, sobald irgendeine Funktion in public das Schema auth wieder
anfasst, und prueft zusaetzlich, dass das Recht entzogen bleibt.
This commit is contained in:
402
db/migrations/20260924140000_ohne_auth_uid.sql
Normal file
402
db/migrations/20260924140000_ohne_auth_uid.sql
Normal file
@@ -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
|
||||
$$;
|
||||
Reference in New Issue
Block a user