Files
alpenwerk-hr/db/migrations/20260908120000_passwort_anmeldung_und_benutzerverwaltung.sql
Andrei Laas cbde8cf3a8
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m52s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Passwort-Anmeldung neben Entra, mit Enddatum im Schema
2026-09-08 16:32:58 +02:00

714 lines
32 KiB
PL/PgSQL

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