From 206a7c6eba40aca81216228a985fec005746bad7 Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Tue, 8 Sep 2026 15:36:48 +0200 Subject: [PATCH] Zeilenschutz auf vier Tabellen nachziehen, und den Zustand pruefen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Neuaufbau der Datenbank aus den Migrationen am 26.08. blieben cost_centers, position_cost_centers, onboarding_tasks und offboarding_tasks ohne Row Level Security: sie verlassen sich auf den Ereignis-Trigger ensure_rls, den eine spaetere Migration erst anlegt. Ein Ereignis-Trigger wirkt nur nach vorne. Die Policies auf diesen Tabellen existieren und wurden nie ausgewertet — PostgreSQL befragt sie nur bei eingeschaltetem RLS. In jeder Aufstellung der Policies sah es richtig aus. Der CI-Job prueft ab jetzt den Zustand nach dem Lauf, nicht nur dass die Dateien durchlaufen. Genau in diesem Zwischenraum ist der Fehler durchgekommen. --- .github/workflows/ci.yml | 71 +++++++++ ...rls_fuer_kostenstellen_und_checklisten.sql | 150 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 db/migrations/20260908100000_rls_fuer_kostenstellen_und_checklisten.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5111c4..bd29eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,77 @@ jobs: echo "$ausgabe" | grep -q "^Nichts anzuwenden" \ || { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; } + # Dass die Migrationen durchlaufen, heisst nicht, dass das Ergebnis + # stimmt. Bis hierher prüft dieser Job nur, dass keine Datei abbricht — + # und genau daran ist ein Fehler vorbeigekommen: vier Tabellen aus dem + # August (Kostenstellen, On-/Offboarding) standen ohne Zeilenschutz da, + # weil sie sich auf den Ereignis-Trigger `ensure_rls` verliessen, den + # eine spätere Migration erst anlegt. Alle Dateien liefen sauber durch, + # die Policies standen im Katalog, und ausgewertet wurde keine davon. + # + # Deshalb wird ab hier der **Zustand** geprüft, nicht der Durchlauf. + # Nachgezogen hat das 20260908100000; dort steht dieselbe Gegenprobe. + # Sie läuft aber nur einmal, beim Anwenden jener Datei. Der Schritt hier + # läuft bei jedem Push gegen eine frisch aufgebaute Datenbank und fängt + # deshalb auch die *nächste* Tabelle, die es wieder vergisst. + - name: Zugriffsschutz nach dem Lauf + env: + PGPASSWORD: ci + run: | + set -euo pipefail + + frage() { + psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -At -c "$1" + } + + # relkind in ('r','p'): gewöhnliche und partitionierte Tabellen. + ohne_rls=$(frage " + select coalesce(string_agg(c.relname, ', ' order by c.relname), '') + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p') + and not c.relrowsecurity") + + # RLS ohne Policy ist nicht offen, sondern leer — kein Leck, aber + # eine Tabelle, aus der nie etwas zurückkommt. + ohne_policy=$(frage " + select coalesce(string_agg(c.relname, ', ' order by c.relname), '') + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p') + and not exists (select 1 from pg_policy p where p.polrelid = c.oid)") + + # Das Netz selbst: fehlt oder schläft es, ist die nächste neue + # Tabelle wieder ungeschützt. + netz=$(frage " + select count(*) from pg_event_trigger + where evtname = 'ensure_rls' and evtenabled <> 'D'") + + fehler=0 + if [ -n "$ohne_rls" ]; then + echo "Ohne Row Level Security: $ohne_rls" + fehler=1 + fi + if [ -n "$ohne_policy" ]; then + echo "Mit Row Level Security, aber ohne jede Policy: $ohne_policy" + fehler=1 + fi + if [ "$netz" != "1" ]; then + echo "Der Ereignis-Trigger ensure_rls fehlt oder ist abgeschaltet." + fehler=1 + fi + + if [ "$fehler" -ne 0 ]; then + echo + echo "Jede Tabelle in public braucht RLS und mindestens eine Policy." + echo "Nachziehen in einer neuen Migration, nicht in einer bereits angewendeten." + exit 1 + fi + + echo "Zugriffsschutz vollstaendig: jede Tabelle mit RLS und Policy, ensure_rls aktiv." + # Die Integrationstests brauchen einen befüllten Bestand; der Seed lag # in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe # README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare diff --git a/db/migrations/20260908100000_rls_fuer_kostenstellen_und_checklisten.sql b/db/migrations/20260908100000_rls_fuer_kostenstellen_und_checklisten.sql new file mode 100644 index 0000000..e248edd --- /dev/null +++ b/db/migrations/20260908100000_rls_fuer_kostenstellen_und_checklisten.sql @@ -0,0 +1,150 @@ +-- Row Level Security auf den vier Tabellen nachziehen, die ohne sie stehen. +-- +-- ═══ Was passiert ist ═══ +-- +-- `cost_centers` und `position_cost_centers` (20260816100000), +-- `onboarding_tasks` (20260817100000) und `offboarding_tasks` (20260818100000) +-- schalten RLS nicht selbst ein. Sie verlassen sich auf den Ereignis-Trigger +-- `ensure_rls`, der bei jeder neu angelegten Tabelle sofort einschaltet — die +-- Kostenstellen-Migration sagt das in ihrem Kommentar ausdrücklich. +-- +-- Der Trigger kam aber erst mit 20260819100000 ins Repository, also **drei +-- Tage nach** der ersten dieser Tabellen. Solange er nur von Hand in der +-- damaligen Datenbank stand, ging die Rechnung auf. Beim Aufbau aus den +-- Migrationen geht sie nicht auf: zum Zeitpunkt der vier `create table` gibt +-- es den Trigger noch nicht, und ein Ereignis-Trigger wirkt nur nach vorne — +-- er schaltet nichts nach, was vor ihm entstanden ist. +-- +-- Genau so ist die heutige Datenbank entstanden (Neuaufbau aus den +-- Migrationen am 26.08.2026). Nachgemessen am 08.09.2026: +-- +-- cost_centers RLS aus +-- position_cost_centers RLS aus +-- onboarding_tasks RLS aus +-- offboarding_tasks RLS aus +-- … die übrigen 15 RLS an +-- +-- ═══ Warum das zählt ═══ +-- +-- Die Policies auf diesen vier Tabellen existieren — sie sind nur wirkungslos. +-- PostgreSQL wertet Policies ausschliesslich aus, wenn RLS an der Tabelle +-- eingeschaltet ist; sonst stehen sie im Katalog und werden nie befragt. Das +-- ist der unangenehme Teil: es sieht in jeder Aufstellung der Policies richtig +-- aus. +-- +-- `alpenwerk_app` hat `select, insert, update, delete on all tables` +-- (20260826120000). Auf diesen vier Tabellen liest und schreibt die Rolle +-- damit ohne jede Prüfung von is_hr_user() — auch ohne Sitzungskontext. Ein +-- Weg von aussen dorthin ist heute nicht bekannt, weil jede lesende Stelle +-- hinter app/(app)/layout.tsx oder requireHrUser() liegt. Aber der Satz „RLS +-- ist die eigentliche Schranke, nicht die Oberfläche" gilt für diese vier +-- Tabellen nicht, und genau darauf beruht der Rest des Aufbaus. +-- +-- ═══ Warum nicht die alten Dateien geändert werden ═══ +-- +-- Eine bereits angewendete Migration nachträglich zu ändern hilft keiner +-- Datenbank, die sie schon hinter sich hat — sie wird nie wieder ausgeführt. +-- Es fälschte nur die Geschichte und liesse den Fehler dort stehen, wo er +-- steht. Deshalb eine neue Datei, die den Zustand herstellt. + +-- ── 1. Einschalten ──────────────────────────────────────────────────── +-- +-- `enable row level security` ist folgenlos, wenn RLS bereits an ist. Die +-- Anweisungen laufen deshalb auch auf einer Datenbank, die den Fehler nicht +-- hat — etwa der ursprünglichen, in der der Trigger von Hand stand. +alter table cost_centers enable row level security; +alter table position_cost_centers enable row level security; +alter table onboarding_tasks enable row level security; +alter table offboarding_tasks enable row level security; + +-- ── 2. Die Policies noch einmal setzen ──────────────────────────────── +-- +-- Wortgleich mit den Ursprungsmigrationen: eine Policy je Tabelle, `for all`, +-- und beide Male dieselbe Frage. Erst löschen, dann anlegen — `create policy` +-- kennt kein `if not exists`, und auf einer Datenbank, die sie schon trägt, +-- ist das Paar ein Nichts. +-- +-- Warum überhaupt, wo sie doch da sind: bis eben wurden sie nie ausgewertet. +-- Eine Policy, die nie gegriffen hat, ist keine geprüfte Policy. Sie hier +-- neben dem Einschalten stehen zu haben heisst, dass beides zusammen aus einer +-- Datei kommt und nicht auseinanderlaufen kann. +drop policy if exists cost_centers_hr_all on cost_centers; +create policy cost_centers_hr_all on cost_centers + for all using (is_hr_user()) with check (is_hr_user()); + +drop policy if exists position_cost_centers_hr_all on position_cost_centers; +create policy position_cost_centers_hr_all on position_cost_centers + for all using (is_hr_user()) with check (is_hr_user()); + +drop policy if exists onboarding_tasks_hr_all on onboarding_tasks; +create policy onboarding_tasks_hr_all on onboarding_tasks + for all using (is_hr_user()) with check (is_hr_user()); + +drop policy if exists offboarding_tasks_hr_all on offboarding_tasks; +create policy offboarding_tasks_hr_all on offboarding_tasks + for all using (is_hr_user()) with check (is_hr_user()); + +-- ── 3. Das Sicherheitsnetz selbst prüfen ────────────────────────────── +-- +-- Der Trigger soll ab jetzt tun, worauf sich die vier Tabellen umsonst +-- verlassen haben. Fehlt er, ist die nächste neue Tabelle wieder offen — und +-- das fiele erst beim nächsten Mal auf, auf dieselbe Weise. +do $$ +begin + if not exists (select 1 from pg_event_trigger where evtname = 'ensure_rls') then + raise exception + 'Der Ereignis-Trigger ensure_rls fehlt. Er wird von 20260819100000 angelegt — ohne ihn steht jede neu angelegte Tabelle ohne Zeilenschutz da.'; + end if; + + if (select evtenabled from pg_event_trigger where evtname = 'ensure_rls') = 'D' then + raise exception + 'Der Ereignis-Trigger ensure_rls ist abgeschaltet. Einschalten mit: alter event trigger ensure_rls enable;'; + end if; +end; +$$; + +-- ── 4. Gegenprobe über das ganze Schema ─────────────────────────────── +-- +-- Nicht nur über die vier von oben: geprüft wird der Zustand, nicht die +-- Änderung. Eine Prüfung, die nur nachsieht, was die Datei gerade getan hat, +-- bestätigt sich selbst. +-- +-- Sie läuft genau einmal — beim Anwenden dieser Datei. Damit ist der heutige +-- Stand belegt, der morgige nicht: eine spätere Migration, die eine Tabelle +-- ohne Zeilenschutz anlegt, sieht diese Prüfung nie wieder. Dafür steht +-- derselbe Abgleich im CI-Lauf (.github/workflows/ci.yml, Job „migrationen"), +-- der bei jedem Push gegen eine frisch aufgebaute Datenbank läuft. +do $$ +declare + v_ohne_rls text; + v_ohne_policy text; +begin + -- `relkind in ('r','p')`: gewöhnliche und partitionierte Tabellen. Sichten + -- und Sequenzen tragen keine Policies und gehören nicht hierher. + select string_agg(c.relname, ', ' order by c.relname) into v_ohne_rls + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p') + and not c.relrowsecurity; + + if v_ohne_rls is not null then + raise exception 'Ohne Row Level Security in public: %. Jede Tabelle braucht ihn.', v_ohne_rls; + end if; + + -- Die zweite Hälfte derselben Frage. RLS ohne Policy ist nicht offen, + -- sondern leer — kein Leck, aber eine Tabelle, aus der nichts zurückkommt. + -- In einer Personalanwendung zeigt sich das als „die Liste ist leer" und + -- wird für einen Datenfehler gehalten, nicht für eine fehlende Policy. + select string_agg(c.relname, ', ' order by c.relname) into v_ohne_policy + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p') + and not exists (select 1 from pg_policy p where p.polrelid = c.oid); + + if v_ohne_policy is not null then + raise exception 'Mit Row Level Security, aber ohne jede Policy: %. Diese Tabellen liefern nichts zurück.', v_ohne_policy; + end if; +end; +$$;