diff --git a/db/migrations/20260908120000_passwort_anmeldung_und_benutzerverwaltung.sql b/db/migrations/20260908120000_passwort_anmeldung_und_benutzerverwaltung.sql new file mode 100644 index 0000000..7e49f5b --- /dev/null +++ b/db/migrations/20260908120000_passwort_anmeldung_und_benutzerverwaltung.sql @@ -0,0 +1,713 @@ +-- Anmeldung mit Passwort neben Entra ID, und eine Benutzerverwaltung. +-- +-- ═══ Warum es diesen zweiten Weg gibt ═══ +-- +-- Fünf Mitarbeiterinnen des Kunden (@manner.com) sollen die Anwendung testen, +-- dazu ein Testzugang für loudspring. Manner hat einen eigenen Entra-Mandanten; +-- eine Gasteinladung müsste deren IT einrichten und dauert Wochen. Die Daten im +-- System sind zu diesem Zeitpunkt synthetisch. +-- +-- ═══ Was dieser Weg kostet ═══ +-- +-- Er hebelt aus, was der Mandant durchsetzt: Mehrfaktor, bedingten Zugriff, +-- Sperrung beim Austritt. Genau davor warnt der Kommentar in actions/auth.ts, +-- und die Warnung bleibt richtig. Das Anmeldeformular steht ausserdem offen im +-- Internet (alpenwerk.elycon.solutions, kein VPN), und das Initialpasswort ist +-- allen sechs Personen gemeinsam und damit bekannt. +-- +-- Deshalb trägt dieser Weg ein **eingebautes Ende**: `gueltig_bis` mit einer +-- Prüfbedingung, die kein Wert nach dem 08.10.2026 passieren lässt. Danach +-- nimmt die Anmeldung kein Passwort mehr an — ohne Zutun, ohne dass sich jemand +-- erinnern muss, und ohne dass es sich über die Oberfläche verlängern liesse. +-- Wer verlängern will, schreibt eine Migration und sieht dabei diesen Absatz. +-- +-- **Vor den echten Manner-Daten muss der Passwortpfad weg sein.** Das ist keine +-- Empfehlung, sondern die Bedingung, unter der er überhaupt entstanden ist. +-- +-- ═══ Die vier Schranken ═══ +-- +-- 1. Erzwungener Wechsel solange muss_wechseln steht, gibt is_hr_user() +-- false zurück — also kein Datenzugriff, nirgends +-- 2. Initialfrist 7 Tage; ein nie benutzter Zugang läuft ab +-- 3. Sperre 5 Fehlversuche → 15 Minuten zu +-- 4. Enddatum 08.10.2026, per Prüfbedingung festgenagelt +-- +-- Schranke 1 ist der Kern: sie sitzt nicht in der Oberfläche, sondern in der +-- Funktion, die alle 25 RLS-Policies aufrufen. Weder ein direkter Aufruf einer +-- Adresse noch eine Server Action noch ein Route Handler kommt daran vorbei, +-- weil keiner von ihnen ohne Policies auskommt. + +-- ══════════════════════════════════════════════════════════════════════ +-- 1. Die Tabelle +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Ein Passwort je Konto, Schlüssel ist app_users.id. Getrennt von profiles, +-- weil das zwei verschiedene Dinge sind: profiles sagt, was jemand darf, +-- app_passwoerter sagt nur, wie er sich ausweist. Ein Entra-Konto hat keine +-- Zeile hier, und das ist die ganze Unterscheidung. +create table if not exists app_passwoerter ( + user_id uuid primary key references app_users(id) on delete cascade, + + -- bcrypt aus pgcrypto. Der Hash verlässt die Datenbank nie: geprüft wird in + -- app_passwort_pruefen(), und an die Tabelle kommt die Anwendungsrolle nicht + -- heran (siehe Policy unten). + -- + -- bcrypt schneidet bei 72 Byte ab. Für sechs Testzugänge ohne Belang, aber + -- es steht hier, damit es niemand später als Fehler sucht. + hash text not null, + + -- Solange das steht, ist der Zugang wertlos: is_hr_user() liefert false. + muss_wechseln boolean not null default true, + + -- Ende der Frist für das Initialpasswort. Wird beim Anlegen und bei jedem + -- Zurücksetzen neu gestellt. + initialpasswort_bis timestamptz not null default now() + interval '7 days', + + -- Das Ende des Passwortpfads insgesamt. Fester Tag, nicht „einen Monat ab + -- Anlage": ein rollendes Fenster liefe mit jedem neuen Zugang weiter, und + -- genau das soll es nicht. + gueltig_bis date not null default date '2026-10-08', + + letzte_anmeldung_am timestamptz, + fehlversuche integer not null default 0, + gesperrt_bis timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + -- Die Prüfbedingung ist der eigentliche Schutz, nicht der Vorgabewert. Ein + -- Vorgabewert lässt sich mit einem Feld im Formular übergehen; hier weist die + -- Datenbank jeden späteren Tag ab. Verlängern heisst: diese Bedingung in + -- einer neuen Migration ändern, bewusst und nachvollziehbar. + constraint chk_passwort_pfad_endet check (gueltig_bis <= date '2026-10-08'), + constraint chk_fehlversuche check (fehlversuche >= 0) +); + +comment on table app_passwoerter is + 'Anmeldung mit Passwort — Übergangslösung für die Testphase. Endet am 08.10.2026 (chk_passwort_pfad_endet).'; + +alter table app_passwoerter enable row level security; + +-- ── Die Policy, die nichts erlaubt ──────────────────────────────────── +-- +-- Das ist Absicht und kein Versehen. Diese Tabelle wird von der Anwendung +-- **nie** direkt gelesen oder geschrieben, auch nicht von HR: jeder Zugriff +-- läuft über die SECURITY-DEFINER-Funktionen weiter unten, die als +-- Eigentümerin laufen und an der Policy vorbeikommen. Damit kann kein +-- Anwendungscode — heute nicht und nach keiner künftigen Änderung — einen Hash +-- auslesen. +-- +-- Warum überhaupt eine Policy, wenn sie nichts zulässt: eine Tabelle mit RLS +-- und *ohne* jede Policy ist von aussen nicht von einer vergessenen zu +-- unterscheiden, und der CI-Lauf weist sie deshalb zurück +-- (.github/workflows/ci.yml, „Zugriffsschutz nach dem Lauf"). Diese Zeile sagt +-- ausdrücklich: hier kommt niemand direkt heran, und das ist gewollt. +drop policy if exists app_passwoerter_kein_direktzugriff on app_passwoerter; +create policy app_passwoerter_kein_direktzugriff on app_passwoerter + for all using (false) with check (false); + +drop trigger if exists trg_app_passwoerter_touch on app_passwoerter; +create trigger trg_app_passwoerter_touch +before update on app_passwoerter +for each row execute function fn_touch_updated_at(); + +-- ══════════════════════════════════════════════════════════════════════ +-- 2. Regeln für ein Passwort +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Liefert die Beanstandung im Klartext oder null, wenn nichts zu beanstanden +-- ist. Als Rückgabe statt als Ausnahme, damit der Aufrufer entscheidet, wie er +-- sie zeigt. Die Oberfläche spiegelt dieselben Regeln in lib/passwort.ts, damit +-- sie schon beim Tippen sichtbar sind — verbindlich ist diese Funktion hier. +create or replace function app_passwort_regeln(p_passwort text) +returns text +language sql +immutable +set search_path = public, pg_temp +as $$ + select case + when p_passwort is null or length(p_passwort) < 12 + then 'Das Passwort muss mindestens 12 Zeichen lang sein.' + when length(p_passwort) > 72 + then 'Das Passwort darf höchstens 72 Zeichen lang sein.' + when p_passwort !~ '[[:upper:]]' + then 'Das Passwort muss mindestens einen Grossbuchstaben enthalten.' + when p_passwort !~ '[[:lower:]]' + then 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.' + when p_passwort !~ '[[:digit:]]' + then 'Das Passwort muss mindestens eine Ziffer enthalten.' + else null + end; +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- 3. Die Prüfung beim Anmelden +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Die einzige Stelle, an der ein Passwort geprüft wird. Ohne Sitzungskontext — +-- den gibt es an dieser Stelle ja noch nicht, er entsteht erst aus dem +-- Ergebnis. Deshalb SECURITY DEFINER und deshalb **keine** Prüfung auf HR: +-- wer sich anmelden darf, ist eine andere Frage als wer etwas sehen darf. Die +-- zweite beantwortet is_hr_user(), und zwar bei jeder Abfrage neu. +-- +-- Rückgabe ist app_users.id oder null. Nie ein Grund: die Anmeldemaske soll +-- nicht verraten, ob eine Adresse überhaupt bekannt ist. +create or replace function app_passwort_pruefen(p_email text, p_passwort text) +returns uuid +language plpgsql +volatile +security definer +set search_path = public, pg_temp +as $$ +declare + v_email text := lower(btrim(coalesce(p_email, ''))); + k app_passwoerter%rowtype; + v_user app_users%rowtype; +begin + if v_email = '' or coalesce(p_passwort, '') = '' then + return null; + end if; + + select u.* into v_user from app_users u where lower(u.email) = v_email limit 1; + + if found then + select * into k from app_passwoerter where user_id = v_user.id for update; + end if; + + -- Unbekannte Adresse oder Konto ohne Passwort: trotzdem einmal rechnen. + -- Ohne das antwortet die Anmeldung für unbekannte Adressen spürbar schneller + -- als für bekannte, und damit liesse sich die Belegschaft durchprobieren. + -- Derselbe Aufwand wie ein echter Vergleich, dasselbe Ergebnis wie ein + -- falsches Passwort. + if k.user_id is null then + perform crypt(p_passwort, gen_salt('bf', 12)); + return null; + end if; + + -- Gesperrt: der Zähler wird **nicht** erhöht. Sonst verlängert jeder weitere + -- Versuch die Sperre, und wer jemanden aussperren will, hält sie beliebig + -- offen. + if k.gesperrt_bis is not null and k.gesperrt_bis > now() then + return null; + end if; + + -- Das Ende des Passwortpfads. Läuft ohne Zutun ab; siehe Kopf der Datei. + if current_date > k.gueltig_bis then + return null; + end if; + + -- Ein nie benutzter Zugang mit dem gemeinsamen Initialpasswort läuft nach + -- sieben Tagen ab. Zurücksetzen stellt die Frist neu. + if k.muss_wechseln and now() > k.initialpasswort_bis then + return null; + end if; + + if k.hash <> crypt(p_passwort, k.hash) then + update app_passwoerter + set fehlversuche = case when fehlversuche + 1 >= 5 then 0 else fehlversuche + 1 end, + gesperrt_bis = case when fehlversuche + 1 >= 5 then now() + interval '15 minutes' else gesperrt_bis end + where user_id = k.user_id; + + -- Protokolliert wird nur für bekannte Konten. Für unbekannte Adressen + -- entstünde sonst ein Schreibpfad, den jeder Fremde beliebig oft auslösen + -- kann — eine Protokolltabelle, die von aussen vollgeschrieben wird. + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (v_user.id, coalesce(v_user.full_name, v_user.email), 'Anmeldung fehlgeschlagen', + v_user.email, + case when k.fehlversuche + 1 >= 5 + then 'Falsches Passwort, Konto für 15 Minuten gesperrt' + else 'Falsches Passwort (' || (k.fehlversuche + 1) || '. Versuch)' end); + return null; + end if; + + update app_passwoerter + set fehlversuche = 0, + gesperrt_bis = null, + letzte_anmeldung_am = now() + where user_id = k.user_id; + + update app_users set last_seen_at = now() where id = k.user_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (v_user.id, coalesce(v_user.full_name, v_user.email), 'Anmeldung', v_user.email, + case when k.muss_wechseln then 'Passwort, Wechsel steht aus' else 'Passwort' end); + + return k.user_id; +end; +$$; + +comment on function app_passwort_pruefen(text, text) is + 'Prüft E-Mail und Passwort und liefert app_users.id oder null. Nennt nie einen Grund — die Anmeldemaske soll keine Adressen bestätigen.'; + +-- ══════════════════════════════════════════════════════════════════════ +-- 4. Steht ein Wechsel aus? +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Für die Oberfläche: lib/shell-data.ts fragt danach, um auf +-- /passwort-aendern umzuleiten statt eine leere Anwendung zu zeigen. Die +-- Absicherung hängt nicht daran — die steckt in is_hr_user(). +create or replace function app_muss_passwort_wechseln() +returns boolean +language sql +stable +security definer +set search_path = public, pg_temp +as $$ + select exists ( + select 1 from app_passwoerter k + where k.user_id = app_current_user_id() and k.muss_wechseln + ); +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- 5. Die Schranke: is_hr_user() kennt jetzt den Passwortzwang +-- ══════════════════════════════════════════════════════════════════════ +-- +-- `create or replace` behält die Funktion samt ihrer Kennung und ihren +-- Rechten; die 25 Policies, die sie aufrufen, merken davon nichts und gelten +-- unverändert weiter. +-- +-- Warum der Zwang **hier** steht und nicht nur im Layout: ein Layout schützt +-- Seiten. Es schützt keine Server Action, keinen Route Handler und keine +-- Abfrage, die morgen jemand schreibt. Diese Funktion schützt alles davon auf +-- einmal, weil ohne sie keine Zeile aus keiner Tabelle kommt. +-- +-- Die Kehrseite, damit sie nicht als Fehler gemeldet wird: eine Person mit +-- offenem Passwortwechsel sieht die Anwendung vollständig leer — auch die +-- Benutzerverwaltung. Der Weg dort heraus führt ausschliesslich über +-- app_passwort_aendern(), und die kommt ohne is_hr_user() aus. +create or replace function is_hr_user() +returns boolean +language sql +security definer +set search_path = public, pg_temp +stable +as $$ + select exists ( + select 1 from profiles p + where p.id = app_current_user_id() + and p.role = 'hr' + and p.is_active = true + and not exists ( + select 1 from app_passwoerter k + where k.user_id = p.id and k.muss_wechseln + ) + ); +$$; + +comment on function is_hr_user() is + 'Aktive HR-Person ohne offenen Passwortwechsel. Aufgerufen von allen RLS-Policies — die eigentliche Zugriffsschranke.'; + +-- ══════════════════════════════════════════════════════════════════════ +-- 6. Sein eigenes Passwort wechseln +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Braucht eine Sitzung, aber **nicht** is_hr_user() — sonst käme niemand je +-- aus dem erzwungenen Wechsel heraus. Das ist die einzige Ausnahme, und sie +-- ist eng: die Funktion fasst genau eine Zeile an, die der aufrufenden Person. +create or replace function app_passwort_aendern(payload jsonb) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid := app_current_user_id(); + v_alt text := payload->>'altes_passwort'; + v_neu text := payload->>'neues_passwort'; + v_fehler text; + k app_passwoerter%rowtype; + v_name text; +begin + if v_id is null then + raise exception 'Nicht angemeldet.'; + end if; + + select * into k from app_passwoerter where user_id = v_id for update; + if not found then + raise exception 'Für dieses Konto gibt es keine Anmeldung mit Passwort.'; + end if; + + if k.gesperrt_bis is not null and k.gesperrt_bis > now() then + raise exception 'Das Konto ist vorübergehend gesperrt. Bitte später erneut versuchen.'; + end if; + + -- Auch hier zählt der Fehlversuch. Ohne das wäre dieses Formular eine + -- Stelle, an der sich das alte Passwort unbegrenzt durchprobieren lässt — + -- die Anmeldemaske daneben ist begrenzt. + if k.hash <> crypt(coalesce(v_alt, ''), k.hash) then + update app_passwoerter + set fehlversuche = case when fehlversuche + 1 >= 5 then 0 else fehlversuche + 1 end, + gesperrt_bis = case when fehlversuche + 1 >= 5 then now() + interval '15 minutes' else gesperrt_bis end + where user_id = v_id; + raise exception 'Das bisherige Passwort stimmt nicht.'; + end if; + + v_fehler := app_passwort_regeln(v_neu); + if v_fehler is not null then + raise exception '%', v_fehler; + end if; + + -- Das gemeinsame Initialpasswort noch einmal zu setzen wäre ein Wechsel, der + -- nichts wechselt. + if k.hash = crypt(v_neu, k.hash) then + raise exception 'Das neue Passwort muss sich vom bisherigen unterscheiden.'; + end if; + + update app_passwoerter + set hash = crypt(v_neu, gen_salt('bf', 12)), + muss_wechseln = false, + fehlversuche = 0, + gesperrt_bis = null + where user_id = v_id; + + select coalesce(u.full_name, u.email) into v_name from app_users u where u.id = v_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (v_id, coalesce(v_name, 'Unbekannt'), 'Passwort geändert', coalesce(v_name, 'Unbekannt'), + 'Von der Person selbst'); +end; +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- 7. Benutzerverwaltung +-- ══════════════════════════════════════════════════════════════════════ + +-- ── Anlegen ─────────────────────────────────────────────────────────── +-- +-- Legt bei Bedarf das Konto an, hängt ein Passwort daran und schaltet +-- wahlweise gleich frei. Gibt es zu der Adresse schon ein Konto — etwa weil +-- die Person sich bereits über Entra angemeldet hat —, bekommt genau dieses +-- das Passwort. Ein zweites Konto zur selben Adresse wäre eine zweite +-- Identität mit eigener Historie und eigenen Notizen. +create or replace function app_benutzer_anlegen(payload jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_email text := lower(btrim(coalesce(payload->>'email', ''))); + v_name text := nullif(btrim(coalesce(payload->>'full_name', '')), ''); + v_passwort text := payload->>'passwort'; + v_hr boolean := coalesce((payload->>'hr_zugang')::boolean, false); + v_fehler text; + v_id uuid; + v_neu boolean := false; +begin + perform require_hr_admin(); + + if v_email = '' or position('@' in v_email) = 0 then + raise exception 'Es wurde keine gültige E-Mail-Adresse angegeben.'; + end if; + + v_fehler := app_passwort_regeln(v_passwort); + if v_fehler is not null then + raise exception '%', v_fehler; + end if; + + select u.id into v_id from app_users u where lower(u.email) = v_email limit 1; + + if v_id is null then + v_id := gen_random_uuid(); + v_neu := true; + -- Kein oid von Entra, also eine Kennung, die als solche erkennbar ist. + -- Vorbild: 'legacy:' aus 20260805140000. + insert into app_users (id, external_id, email, full_name) + values (v_id, 'passwort:' || v_id::text, v_email, v_name); + elsif v_name is not null then + update app_users set full_name = v_name where id = v_id and full_name is null; + end if; + + if exists (select 1 from app_passwoerter where user_id = v_id) then + raise exception 'Für % besteht bereits eine Anmeldung mit Passwort. Stattdessen das Passwort zurücksetzen.', v_email; + end if; + + insert into app_passwoerter (user_id, hash, muss_wechseln, initialpasswort_bis) + values (v_id, crypt(v_passwort, gen_salt('bf', 12)), true, now() + interval '7 days'); + + if v_hr then + insert into profiles (id, email, full_name, role, is_active, created_by) + values (v_id, v_email, v_name, 'hr', true, app_current_user_id()) + on conflict (id) do update set is_active = true, role = 'hr'; + end if; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (app_current_user_id(), current_actor_name(), 'Benutzer:in angelegt', v_email, + case when v_neu then 'Neues Konto' else 'Passwort zu bestehendem Konto' end || + case when v_hr then ', mit HR-Zugang' else ', ohne HR-Zugang' end || + ', Initialpasswort gültig bis ' || to_char(now() + interval '7 days', 'DD.MM.YYYY')); + + return jsonb_build_object('user_id', v_id, 'neu', v_neu); +end; +$$; + +-- ── Passwort zurücksetzen ───────────────────────────────────────────── +-- +-- Zurück auf das Initialpasswort, Wechselzwang wieder an, Frist wieder sieben +-- Tage, Sperre weg. Das ist der Weg für „ich komme nicht mehr hinein" und +-- ersetzt jeden Selbstbedienungspfad per E-Mail — den gibt es hier bewusst +-- nicht. +create or replace function app_passwort_zuruecksetzen(payload jsonb) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid := (payload->>'user_id')::uuid; + v_passwort text := payload->>'passwort'; + v_fehler text; + v_email text; +begin + perform require_hr_admin(); + + v_fehler := app_passwort_regeln(v_passwort); + if v_fehler is not null then + raise exception '%', v_fehler; + end if; + + select u.email into v_email from app_users u where u.id = v_id; + if v_email is null then + raise exception 'Das Konto existiert nicht.'; + end if; + + update app_passwoerter + set hash = crypt(v_passwort, gen_salt('bf', 12)), + muss_wechseln = true, + initialpasswort_bis = now() + interval '7 days', + fehlversuche = 0, + gesperrt_bis = null + where user_id = v_id; + + if not found then + raise exception 'Für dieses Konto gibt es keine Anmeldung mit Passwort.'; + end if; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (app_current_user_id(), current_actor_name(), 'Passwort zurückgesetzt', v_email, + 'Initialpasswort gültig bis ' || to_char(now() + interval '7 days', 'DD.MM.YYYY')); +end; +$$; + +-- ── Freischalten und sperren ────────────────────────────────────────── +-- +-- Auch der Weg für ein Entra-Konto ohne profiles-Zeile: bis heute war das ein +-- `insert` von Hand über psql, und damit hing die Vergabe von Zugriff an einer +-- einzelnen Person mit Datenbankzugang. +-- +-- Zwei Riegel, beide gegen dasselbe: dass sich die Anwendung von innen +-- zusperrt. Ohne sie liesse sich der letzte Zugang entziehen, und +-- wiederherstellen könnte ihn nur noch, wer an die Datenbank kommt — also +-- genau der Zustand, den diese Funktion beseitigen soll. +create or replace function app_zugang_setzen(payload jsonb) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid := (payload->>'user_id')::uuid; + v_aktiv boolean := coalesce((payload->>'aktiv')::boolean, false); + v_user app_users%rowtype; + v_uebrig integer; +begin + perform require_hr_admin(); + + select * into v_user from app_users where id = v_id; + if not found then + raise exception 'Das Konto existiert nicht.'; + end if; + + if v_aktiv then + insert into profiles (id, email, full_name, role, is_active, created_by) + values (v_id, v_user.email, v_user.full_name, 'hr', true, app_current_user_id()) + on conflict (id) do update set is_active = true, role = 'hr'; + else + if v_id = app_current_user_id() then + raise exception 'Sie können sich nicht selbst deaktivieren.'; + end if; + + select count(*) into v_uebrig + from profiles p + where p.role = 'hr' and p.is_active = true and p.id <> v_id; + + if v_uebrig = 0 then + raise exception 'Das ist der letzte aktive HR-Zugang. Erst eine andere Person freischalten, dann diesen entziehen.'; + end if; + + update profiles set is_active = false where id = v_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_aktiv then 'HR-Zugang erteilt' else 'HR-Zugang entzogen' end, + v_user.email, null); +end; +$$; + +-- ── Die Liste ───────────────────────────────────────────────────────── +-- +-- Als Funktion und nicht als Abfrage, weil app_passwoerter für die +-- Anwendungsrolle unerreichbar ist. Zurück kommt, was die Verwaltungsseite +-- zeigt — und kein Hash. +create or replace function app_benutzer_liste() +returns table ( + user_id uuid, + email text, + full_name text, + ist_entra boolean, + hat_passwort boolean, + is_active boolean, + letzte_anmeldung timestamptz, + muss_wechseln boolean, + gesperrt_bis timestamptz, + initialpasswort_bis timestamptz, + gueltig_bis date +) +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +-- Die Ausgabespalten oben sind zugleich plpgsql-Variablen und heissen wie +-- Spalten der abgefragten Tabellen. Ohne diese Zeile entschiede die +-- Namensauflösung zugunsten der Variablen, sobald irgendwann ein Verweis ohne +-- Tabellenpräfix dazukommt — und lieferte still null statt des Werts. +#variable_conflict use_column +begin + -- Nicht `stable`: require_hr_admin() ist volatile, und eine stabile Funktion + -- soll keine volatile aufrufen. Gelesen wird die Liste einmal je Seitenaufbau, + -- zu optimieren gibt es hier nichts. + perform require_hr_admin(); + + return query + select u.id, + u.email, + u.full_name, + u.external_id not like 'passwort:%' as ist_entra, + k.user_id is not null as hat_passwort, + coalesce(p.is_active, false) as is_active, + greatest(u.last_seen_at, k.letzte_anmeldung_am) as letzte_anmeldung, + coalesce(k.muss_wechseln, false) as muss_wechseln, + k.gesperrt_bis, + k.initialpasswort_bis, + k.gueltig_bis + from app_users u + left join profiles p on p.id = u.id + left join app_passwoerter k on k.user_id = u.id + order by u.email; +end; +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- 8. Rechte +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Ausdrücklich nur für die Anwendungsrolle. Der Bestand macht das anders +-- (`revoke all from public; grant execute to public`) — das ist wirkungslos, +-- weil es die Ausführung am Ende wieder allen gibt. Für neue Funktionen wird +-- das nicht fortgeführt; die alten bleiben unangetastet, das gehört in eine +-- eigene Migration. +-- +-- app_passwort_pruefen() ist von aussen auslösbar: die Anmeldemaske steht +-- offen. Begrenzt wird das durch die Sperre nach fünf Versuchen und dadurch, +-- dass für unbekannte Adressen nichts geschrieben wird. +revoke execute on function app_passwort_regeln(text) from public; +revoke execute on function app_passwort_pruefen(text, text) from public; +revoke execute on function app_muss_passwort_wechseln() from public; +revoke execute on function app_passwort_aendern(jsonb) from public; +revoke execute on function app_benutzer_anlegen(jsonb) from public; +revoke execute on function app_passwort_zuruecksetzen(jsonb) from public; +revoke execute on function app_zugang_setzen(jsonb) from public; +revoke execute on function app_benutzer_liste() from public; + +grant execute on function app_passwort_regeln(text) to alpenwerk_app; +grant execute on function app_passwort_pruefen(text, text) to alpenwerk_app; +grant execute on function app_muss_passwort_wechseln() to alpenwerk_app; +grant execute on function app_passwort_aendern(jsonb) to alpenwerk_app; +grant execute on function app_benutzer_anlegen(jsonb) to alpenwerk_app; +grant execute on function app_passwort_zuruecksetzen(jsonb) to alpenwerk_app; +grant execute on function app_zugang_setzen(jsonb) to alpenwerk_app; +grant execute on function app_benutzer_liste() to alpenwerk_app; + +-- ══════════════════════════════════════════════════════════════════════ +-- 9. Gegenprobe +-- ══════════════════════════════════════════════════════════════════════ +-- +-- Geprüft wird die eine Aussage, auf der alles andere ruht: solange ein +-- Passwortwechsel aussteht, gibt is_hr_user() false zurück. Wäre das nicht so, +-- wäre der Zwang eine Beschriftung in der Oberfläche und sonst nichts. +-- +-- Die Probezeilen werden am Ende wieder entfernt; die Migration hinterlässt +-- keine Daten. Geschrieben wird dabei absichtlich direkt in die Tabellen und +-- nicht über die Funktionen von oben — sonst blieben Protokollzeilen zu einer +-- Person stehen, die es nie gab. +do $$ +declare + v_id uuid := gen_random_uuid(); +begin + -- 1. Ohne Sitzungskontext: nie wahr. Das ist die Zusicherung aus + -- 20260730120000 und muss die Änderung überleben. + perform set_config('app.user_id', '', true); + if is_hr_user() then + raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.'; + end if; + + insert into app_users (id, external_id, email, full_name) + values (v_id, 'probe:' || v_id::text, 'probe@example.invalid', 'Probe'); + insert into profiles (id, email, full_name, role, is_active) + values (v_id, 'probe@example.invalid', 'Probe', 'hr', true); + + perform set_config('app.user_id', v_id::text, true); + + -- 2. Freigeschaltet, kein Passwort im Spiel: wahr. Ohne diesen Schritt + -- beweisen die folgenden nichts — eine Funktion, die immer false sagt, + -- bestünde Punkt 3 ebenfalls. + if not is_hr_user() then + raise exception 'is_hr_user() liefert für eine freigeschaltete Person ohne Passwort false — Abbruch.'; + end if; + + -- 3. Derselbe Mensch, jetzt mit offenem Wechsel: falsch. + insert into app_passwoerter (user_id, hash, muss_wechseln) + values (v_id, crypt('Nur-fuer-die-Probe1', gen_salt('bf', 4)), true); + + if is_hr_user() then + raise exception 'is_hr_user() liefert bei offenem Passwortwechsel true — der Zwang wäre umgehbar. Abbruch.'; + end if; + + if not app_muss_passwort_wechseln() then + raise exception 'app_muss_passwort_wechseln() erkennt den offenen Wechsel nicht — Abbruch.'; + end if; + + -- 4. Nach dem Wechsel wieder wahr — sonst käme niemand je wieder hinein. + update app_passwoerter set muss_wechseln = false where user_id = v_id; + if not is_hr_user() then + raise exception 'is_hr_user() bleibt nach dem Passwortwechsel false — Abbruch.'; + end if; + + -- 5. Das Enddatum lässt sich nicht hinausschieben. + begin + update app_passwoerter set gueltig_bis = date '2027-01-01' where user_id = v_id; + raise exception 'gueltig_bis liess sich über den 08.10.2026 hinaus setzen — chk_passwort_pfad_endet greift nicht. Abbruch.'; + exception + when check_violation then null; + end; + + delete from app_passwoerter where user_id = v_id; + delete from profiles where id = v_id; + delete from app_users where id = v_id; + perform set_config('app.user_id', '', true); +end; +$$; + +-- Und der Zustand der neuen Tabelle selbst — dieselbe Frage, die der CI-Lauf +-- nach jedem Durchlauf über das ganze Schema stellt. +do $$ +begin + if not (select relrowsecurity from pg_class where oid = 'app_passwoerter'::regclass) then + raise exception 'app_passwoerter steht ohne Row Level Security. Abbruch.'; + end if; + if not exists (select 1 from pg_policy where polrelid = 'app_passwoerter'::regclass) then + raise exception 'app_passwoerter hat keine Policy. Abbruch.'; + end if; +end; +$$;