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