Zeilenschutz auf vier Tabellen nachziehen, und den Zustand pruefen
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:
71
.github/workflows/ci.yml
vendored
71
.github/workflows/ci.yml
vendored
@@ -117,6 +117,77 @@ jobs:
|
|||||||
echo "$ausgabe" | grep -q "^Nichts anzuwenden" \
|
echo "$ausgabe" | grep -q "^Nichts anzuwenden" \
|
||||||
|| { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; }
|
|| { 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
|
# Die Integrationstests brauchen einen befüllten Bestand; der Seed lag
|
||||||
# in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe
|
# in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe
|
||||||
# README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare
|
# README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user