714 lines
32 KiB
PL/PgSQL
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;
|
|
$$;
|