Zeilenschutz auf vier Tabellen nachziehen, und den Zustand pruefen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s

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.
This commit is contained in:
2026-09-08 15:36:48 +02:00
parent 405d708bc4
commit 206a7c6eba
2 changed files with 221 additions and 0 deletions

View File

@@ -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

View File

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