From 77d9a95f7f3068827a66f035efa7f39fe327fdbd Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 20 Aug 2026 16:34:08 +0200 Subject: [PATCH] Run the database in a container of our own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase was only ever the host: the application has talked to PostgreSQL directly through pg/Kysely for a while. So the move is mostly about supplying what the platform used to supply. Proved before building anything. All 65 migrations replay onto an empty database, and the result matches production exactly — 183 columns, 25 policies, 68 indexes, 84 constraints, identical sets, no diff. The only function missing from the rebuild turned out to matter, see below. What the platform supplied, deploy/db-init now does: - alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21 RLS policies; a role that bypasses them would leave everything working while showing too much, and nobody would notice. - pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is used nowhere — no column default, no function calls uuid_generate_*. - anon, authenticated and service_role as NOLOGIN placeholders. No policy names them; they only carry grants the platform handed out, and a data dump referencing them would fail to restore without them. - A stub `auth` schema. The end state needs none of it — checked: no foreign key, no policy, no column default refers to it. The June 2026 migrations do, and rewriting those would be falsifying history; they describe what was true then. The gap the comparison found: rls_auto_enable() and the ensure_rls event trigger existed only in the running database, created by hand, in no migration. That is the net which forces RLS on every newly created table — the reason a forgotten policy yields an empty table instead of an open one. A rebuild from migrations would silently not have had it: everything works, and the next new table is unprotected. Now a migration (20260819100000), verified by creating a table on the rebuild and confirming RLS came on by itself. Data moves separately, via scripts/umzug-von-supabase.sh: schema from the migrations, then pg_dump --data-only --disable-triggers for the rows. Without --disable-triggers every foreign key trips over load order. RLS does not interfere — none of the 19 tables uses FORCE ROW LEVEL SECURITY, so the owner writes through. The dump is deliberately left on disk afterwards. psql and node come from two `tools`-profile services rather than being installed on the host, so the server needs nothing but Docker. The db service publishes no port at all — reachable only inside the compose network. SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it describes something else entirely; the old name still works so existing .env files keep running. Both were exercised, as was the error when neither is set. The deploy workflow is set to manual-only. Its preconditions were never met — no secrets, and whether the job container can reach the host's Docker daemon is untested — and failing on every push teaches people to ignore red runs. It also needs updating for the new database service before it could work at all. Not verified: none of this has run in an actual container. There is no Docker daemon on this machine. What is verified is the part that decides whether it can work — the schema, on a real empty database. Co-Authored-By: Claude Opus 5 --- .env.example | 47 +++++++--- .github/workflows/deploy.yml | 16 +++- DEPLOYMENT.md | 76 ++++++++++++++-- .../db-init/00-rollen-und-erweiterungen.sql | 82 +++++++++++++++++ deploy/db-init/01-auth-attrappe.sql | 56 ++++++++++++ docker-compose.yml | 91 +++++++++++++++++++ scripts/migrate.mjs | 19 ++-- scripts/umzug-von-supabase.sh | 77 ++++++++++++++++ ...0000_rls_sicherheitsnetz_als_migration.sql | 59 ++++++++++++ 9 files changed, 494 insertions(+), 29 deletions(-) create mode 100644 deploy/db-init/00-rollen-und-erweiterungen.sql create mode 100644 deploy/db-init/01-auth-attrappe.sql create mode 100644 scripts/umzug-von-supabase.sh create mode 100644 supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql diff --git a/.env.example b/.env.example index a5c1a10..25dcbd3 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,42 @@ -# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit -# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem -# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech). +# ── Datenbank ──────────────────────────────────────────────────────── +# +# Die Datenbank läuft als eigener Container (Dienst `db` in +# docker-compose.yml). Diese drei Werte richten ihn ein: +# +# POSTGRES_PASSWORD das des Verwalters (postgres) — nur für Migrationen, +# Sicherungen und Wartung +# APP_DB_PASSWORD das der Anwendungsrolle alpenwerk_app +# POSTGRES_DB Name der Datenbank; Vorgabe alpenwerk +# +# Beide Passwörter selbst erzeugen: `openssl rand -base64 24` +# +# Sie werden nur beim **allerersten** Start ausgewertet, solange das +# Datenverzeichnis leer ist. Ein späterer Wechsel braucht ein +# `alter role … password …` in der laufenden Datenbank. +POSTGRES_PASSWORD= +APP_DB_PASSWORD= +POSTGRES_DB=alpenwerk +POSTGRES_USER=postgres + +# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank +# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port. # # Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der # Sitzungskontext, sollen die Policies nichts zurückgeben statt alles. -# -# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den -# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port -# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer -# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides -# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste -# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die -# Anwendung nur noch mit „max clients reached". -DATABASE_URL= -# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI). -DATABASE_SSL= +# `alpenwerk_app` wird in deploy/db-init genau so angelegt. +DATABASE_URL=postgresql://alpenwerk_app:@db:5432/alpenwerk +# Im Compose-Netz läuft Postgres ohne TLS — die Verbindung verlässt den +# Server nicht. Bei einer Datenbank ausserhalb diesen Wert entfernen. +DATABASE_SSL=false # Verbindungen im Pool; Vorgabe 10. DATABASE_POOL_MAX= +# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter, +# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht +# darf. Bei Betrieb über docker compose wird er nicht gebraucht — der Dienst +# `migrate` setzt ihn selbst aus POSTGRES_PASSWORD zusammen. +MIGRATE_DATABASE_URL= + # ── Anmeldung (Auth.js + Microsoft Entra ID) ───────────────────────── # Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird. # Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0d87362..f36de29 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,9 +10,21 @@ name: Deploy # Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf # git.elycon.solutions.) +# Nur von Hand auslösbar (Actions → Deploy → „Run workflow"). +# +# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die +# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt, +# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist +# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu +# übersehen; dann lieber gar nicht starten. +# +# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch +# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD, +# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst +# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf +# schaltet, muss beides nachziehen. on: - push: - branches: [master] + workflow_dispatch: # Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander # fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 7bd2202..b6f2c89 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -69,12 +69,18 @@ einem eigenen Linux-Server läuft. ## Was wird containerisiert – und was nicht -- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`). -- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über - `DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein - Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS, - Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die - Migrations- und Entwicklungsumgebung, kein Teil des Deployments. +- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank** + (Dienst `db`, `postgres:17-alpine`). Beide zusammen in + `docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`. +- **Nicht mehr Supabase.** Die Anwendung sprach ohnehin unmittelbar mit + PostgreSQL — Supabase war nur der Betreiber. Was die Plattform beisteuerte, + bringt jetzt `deploy/db-init/` mit: die Anwendungsrolle ohne BYPASSRLS, die + zwei benutzten Erweiterungen und eine Attrappe des `auth`-Schemas, die nur + die alten Migrationen von Juni brauchen. Der Umzug steht in + [Abschnitt 3a](#3a-umzug-von-supabase). +- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech), + genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht + zu starten. Die Anwendung merkt keinen Unterschied. - **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt keinen Anmeldedienst, der mit ausgerollt werden müsste. @@ -190,6 +196,64 @@ Build-Argumente braucht es dabei **keine**: das Abbild enthält keine umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`. Dasselbe Abbild läuft damit in Test und Produktion. +### Bei einer leeren Datenbank + +Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das +Schema kommt danach aus den Migrationen: + +```bash +docker compose up -d db +docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an +docker compose up -d --build +``` + +`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit. +Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker. + +## 3a. Umzug von Supabase + +Einmalig, wenn der Bestand noch bei Supabase liegt: + +```bash +SUPABASE_DB_URL='postgresql://postgres:@.supabase.com:5432/postgres' \ + ./scripts/umzug-von-supabase.sh +``` + +Was das Skript tut und warum: + +1. **Schema aus den Migrationen**, nicht aus einem Abzug. Nachgewiesen ist, + dass alle Migrationen auf einer leeren Datenbank durchlaufen und dabei + Spalte für Spalte, Index für Index, Policy für Policy dasselbe ergeben wie + die gewachsene Produktion. Der Weg hat zwei Vorteile: die Buchführung + stimmt danach von selbst, und Supabase-eigene Rechte und Eigentümer kommen + gar nicht erst mit. +2. **Nur die Daten** werden abgezogen (`pg_dump --data-only + --disable-triggers`). Ohne `--disable-triggers` stolpert jede + Fremdschlüsselprüfung über die Ladereihenfolge. RLS steht nicht im Weg — + keine der 19 Tabellen hat `FORCE ROW LEVEL SECURITY`, der Eigentümer + schreibt also durch. +3. Am Ende werden die Zeilenzahlen ausgegeben. **Mit denen bei Supabase + vergleichen**, bevor irgendetwas abgeschaltet wird. + +Der Abzug bleibt als Datei liegen. Erst löschen, wenn die Anwendung gegen die +neue Datenbank nachweislich läuft. + +Danach in `.env`: + +``` +DATABASE_URL=postgresql://alpenwerk_app:@db:5432/alpenwerk +DATABASE_SSL=false +``` + +### Was am `auth`-Schema übrigbleibt + +`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird +im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine +Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen +der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die +Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie +beschreiben, was damals galt. + ## 4. Reverse Proxy + HTTPS Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert diff --git a/deploy/db-init/00-rollen-und-erweiterungen.sql b/deploy/db-init/00-rollen-und-erweiterungen.sql new file mode 100644 index 0000000..75f74de --- /dev/null +++ b/deploy/db-init/00-rollen-und-erweiterungen.sql @@ -0,0 +1,82 @@ +-- Grundlage für einen nackten PostgreSQL-Container. +-- +-- Läuft einmal, beim allerersten Start des `db`-Dienstes: das offizielle +-- Postgres-Abbild führt alles unter /docker-entrypoint-initdb.d aus, solange +-- das Datenverzeichnis noch leer ist. Bei jedem weiteren Start passiert hier +-- nichts mehr — Änderungen daran wirken also nur auf einem frischen Band. +-- +-- Was hier steht, sind die Voraussetzungen, die auf Supabase von der Plattform +-- kamen und die ein eigener Server selbst mitbringen muss. + +-- Das Passwort der Anwendungsrolle kommt aus der Umgebung, damit es nicht in +-- einer Datei im Repository steht. `\set` mit Backticks ist psql-eigene +-- Ersetzung — sie läuft, bevor der Server die Zeile sieht. +\set app_passwort `echo "$APP_DB_PASSWORD"` +select set_config('init.app_passwort', :'app_passwort', false); + +do $$ +declare + v_passwort text := current_setting('init.app_passwort', true); +begin + -- Lieber hier laut scheitern als eine Rolle mit leerem Passwort anlegen. + -- Der Container käme sonst hoch, die Anwendung verbände sich nicht, und der + -- Grund stünde nirgends. + if coalesce(v_passwort, '') = '' then + raise exception 'APP_DB_PASSWORD ist nicht gesetzt — ohne Passwort keine Anwendungsrolle.'; + end if; + + -- ── Die Rolle, mit der die Anwendung arbeitet ─────────────────────── + -- + -- **Ohne BYPASSRLS, und das ist der Punkt.** Die Zugriffsrechte dieser + -- Anwendung liegen in RLS-Policies; fehlt der Sitzungskontext, sollen sie + -- nichts zurückgeben statt alles. Eine Rolle, die RLS umgeht, machte die + -- ganze Absicherung wirkungslos — und niemand würde es merken, weil alles + -- weiter funktioniert, nur eben zu viel. + if not exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then + execute format('create role alpenwerk_app login password %L', v_passwort); + else + execute format('alter role alpenwerk_app with login password %L', v_passwort); + end if; + execute 'alter role alpenwerk_app with nobypassrls'; + + -- ── Rollen, die es auf Supabase gab ───────────────────────────────── + -- + -- `anon`, `authenticated` und `service_role` gehören zu Supabase, nicht zu + -- dieser Anwendung. Keine einzige Policy nennt sie — sie tragen nur Rechte, + -- die die Plattform pauschal vergeben hat. + -- + -- Angelegt werden sie trotzdem, und zwar **ohne Anmelderecht**: ein + -- Datenabzug aus dem alten Bestand enthält `grant`-Anweisungen auf diese + -- Namen, und ohne die Rollen bricht das Einspielen ab. Anmelden kann sich + -- damit niemand, sie sind reine Platzhalter für die Wiederherstellung. + if not exists (select 1 from pg_roles where rolname = 'anon') then + execute 'create role anon nologin'; + end if; + if not exists (select 1 from pg_roles where rolname = 'authenticated') then + execute 'create role authenticated nologin'; + end if; + if not exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'create role service_role nologin'; + end if; +end +$$; + +select set_config('init.app_passwort', '', false); + +-- ── Erweiterungen ───────────────────────────────────────────────────── +-- +-- Nur diese beiden werden gebraucht. `uuid-ossp` stand auf Supabase zwar +-- bereit, wird aber nirgends benutzt (keine Spaltenvorgabe, keine Funktion +-- ruft uuid_generate_*); die Kennungen kommen aus gen_random_uuid(). +-- +-- Beide liegen in `public`, nicht in einem eigenen `extensions`-Schema: der +-- search_path der Anwendung und aller SECURITY-DEFINER-Funktionen ist auf +-- `public, pg_temp` festgenagelt, ein anderes Schema wäre dort unsichtbar. +create extension if not exists pgcrypto; +create extension if not exists pg_trgm; + +-- ── Rechte am Schema ────────────────────────────────────────────────── +-- +-- Die Migrationen vergeben ihre Tabellenrechte selbst. Was sie voraussetzen, +-- ist das Recht, das Schema überhaupt zu betreten. +grant usage on schema public to alpenwerk_app; diff --git a/deploy/db-init/01-auth-attrappe.sql b/deploy/db-init/01-auth-attrappe.sql new file mode 100644 index 0000000..3dff999 --- /dev/null +++ b/deploy/db-init/01-auth-attrappe.sql @@ -0,0 +1,56 @@ +-- Eine Attrappe des Supabase-Schemas `auth` — nur für die Migrationshistorie. +-- +-- ═══ Warum das hier steht, obwohl es niemand mehr braucht ═══ +-- +-- Der **Endstand** des Schemas ist frei von Supabase: kein Fremdschlüssel aus +-- `public` zeigt nach `auth`, keine Policy ruft `auth.uid()`, keine +-- Spaltenvorgabe nennt es. Geprüft, nicht vermutet. +-- +-- Die **Historie** ist es nicht. Die erste Migration von Juni 2026 hängt +-- profiles an `auth.users`, und ein Dutzend Policies dieser Zeit fragen +-- `auth.role() = 'authenticated'`. Abgelöst wurde das erst im August +-- (20260805110000, 20260805140000). Wer die Migrationen von vorne abspielt — +-- die CI tut das bei jedem Lauf, und genau dafür ist sie da —, kommt ohne +-- diese Objekte nicht an der ersten Datei vorbei. +-- +-- Die Alternative wäre, die alten Migrationen umzuschreiben. Das hiesse, +-- Geschichte zu fälschen: die Dateien beschreiben, was damals galt, und ein +-- nachträglich geänderter Stand von Juni ist keiner, der je gelaufen ist. +-- Eine Attrappe, die beim Abspielen trägt und danach leer stehenbleibt, ist +-- die ehrlichere Lösung. +-- +-- Sie ist bewusst **so dumm wie möglich**: keine Rechte für die +-- Anwendungsrolle, keine Zeilen, keine Anmeldung. `auth.users` bleibt leer, +-- die Fremdschlüssel darauf verschwinden im Lauf der Migrationen von selbst. + +create schema if not exists auth; + +-- Nur das, worauf die frühen Fremdschlüssel zeigen. Ohne weitere Spalten: +-- geschrieben wurde in diese Tabelle nie, die Anwendung führt ihre Benutzer +-- in `public.app_users`. +create table if not exists auth.users ( + id uuid primary key +); + +-- Auf Supabase lasen diese beiden aus dem JWT der Anfrage. Hier gibt es keins +-- — und das ist richtig so: der Sitzungskontext dieser Anwendung steht in +-- `app.user_id` und wird von withUser() je Transaktion gesetzt (lib/db). +-- +-- `app_current_user_id()` ruft `auth.uid()` noch auf, gefasst in ein +-- `exception when undefined_function`. Es liefe also auch ohne dieses Schema; +-- mit ihm liefert es null statt in den Ausnahmezweig zu laufen. Das Ergebnis +-- ist dasselbe. +create or replace function auth.uid() returns uuid language sql stable as $$ + select nullif(current_setting('app.user_id', true), '')::uuid +$$; + +create or replace function auth.role() returns text language sql stable as $$ + select case when nullif(current_setting('app.user_id', true), '') is null + then 'anon' else 'authenticated' end +$$; + +-- Niemand ausser dem Eigentümer braucht hier etwas. Die Anwendungsrolle +-- bekommt bewusst kein `usage` — fiele je wieder ein Bezug auf dieses Schema +-- in den laufenden Betrieb, soll er scheitern und nicht stillschweigend +-- funktionieren. +revoke all on schema auth from public; diff --git a/docker-compose.yml b/docker-compose.yml index 24c2775..bbbff84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,86 @@ services: + # Die Datenbank läuft jetzt hier statt bei Supabase. + # + # Was das Schema von der Plattform brauchte, bringt deploy/db-init mit: + # die Anwendungsrolle ohne BYPASSRLS, zwei Erweiterungen und eine Attrappe + # des `auth`-Schemas für die frühen Migrationen. Nachgewiesen ist, dass + # alle Migrationen auf einer leeren Datenbank durchlaufen und dabei + # Spalte für Spalte dasselbe Schema ergeben wie bisher. + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-alpenwerk} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env} + # Vom Startskript gelesen; damit steht das Passwort der Anwendungsrolle + # nicht in einer Datei im Repository. + APP_DB_PASSWORD: ${APP_DB_PASSWORD:?APP_DB_PASSWORD fehlt in .env} + # Ohne das legt initdb die Datenbank mit der Locale des Abbilds an; + # Sortierung von Umlauten ist bei einer Mitarbeiterliste keine + # Nebensache. + LANG: de_AT.UTF-8 + POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-AT --encoding=UTF8" + volumes: + - db-daten:/var/lib/postgresql/data + # Läuft nur beim allerersten Start, solange das Datenverzeichnis leer + # ist. Spätere Änderungen wirken erst auf einem frischen Band. + - ./deploy/db-init:/docker-entrypoint-initdb.d:ro + # Bewusst **kein** `ports:`. Die Datenbank ist nur im Compose-Netz + # erreichbar, nicht vom Host und erst recht nicht von aussen. Wer von + # aussen heranmuss (Sicherung, Migration), tut das über `docker compose + # exec db` — dann gibt es keinen offenen Port, den jemand vergisst. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-alpenwerk}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + # ── Werkzeuge, die nicht mitlaufen ─────────────────────────────────── + # + # Beide stehen im Profil `tools` und starten deshalb bei `docker compose + # up` **nicht** mit. Sie werden einzeln gerufen (`docker compose run --rm + # migrate`) und sind der Grund, warum auf dem Host weder Node noch ein + # psql installiert sein muss — nur Docker. + + # Spielt ausstehende Migrationen gegen den db-Container ein. + migrate: + profiles: ["tools"] + image: node:22-alpine + working_dir: /repo + volumes: + - .:/repo + environment: + # Der direkte Zugang: im Compose-Netz gibt es keinen Pooler, und + # scripts/migrate.mjs erwartet ohnehin die unmittelbare Verbindung. + MIGRATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-alpenwerk} + DATABASE_SSL: "false" + depends_on: + db: + condition: service_healthy + # `pg` liegt im Repository; fehlt node_modules auf dem Server, wird es + # einmalig nachgeladen, ohne package.json anzufassen. + entrypoint: + - sh + - -c + - 'test -d node_modules/pg || npm install --no-save --no-audit --no-fund pg@8 >/dev/null; node scripts/migrate.mjs "$@"' + - -- + + # psql und pg_dump, ohne sie auf dem Host zu installieren. + psql: + profiles: ["tools"] + image: postgres:17-alpine + volumes: + - .:/repo + working_dir: /repo + environment: + PGPASSWORD: ${POSTGRES_PASSWORD} + depends_on: + db: + condition: service_healthy + entrypoint: ["psql", "-v", "ON_ERROR_STOP=1", "-h", "db", "-U", "${POSTGRES_USER:-postgres}", "-d", "${POSTGRES_DB:-alpenwerk}"] + app: # Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL, # AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede @@ -6,6 +88,9 @@ services: build: context: . restart: unless-stopped + depends_on: + db: + condition: service_healthy # Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar # ist die App damit ausschliesslich über den Reverse Proxy, der TLS # beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen, @@ -31,3 +116,9 @@ services: - | echo "0 3 * * * /bin/sh -c 'wget -q -O- --header=\"Authorization: Bearer \$$CRON_SECRET\" http://app:3000/api/cron/apply-pending-changes >> /var/log/cron.log 2>&1'" > /etc/crontabs/root crond -f -d 8 + +volumes: + # Benannt, nicht als Pfad auf dem Host: `docker compose down` lässt es + # stehen, und nur ein ausdrückliches `down -v` löscht es. Der Unterschied + # zwischen „Dienst neu gestartet" und „Personaldaten weg". + db-daten: diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index a4620d1..268385a 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -42,15 +42,20 @@ const argumente = new Set(process.argv.slice(2)); const nurZeigen = argumente.has("--dry-run"); const baseline = argumente.has("--baseline"); -// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus). -// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543), -// und der verträgt kein `create schema` in einer Transaktion mit mehreren -// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung. -const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL; +// Der **direkte** Zugang als Verwalter — nicht DATABASE_URL. +// +// DATABASE_URL gehört der Anwendungsrolle, und die darf bewusst kein Schema +// ändern; ausserdem zeigte sie bei Supabase auf den Transaktions-Pooler, der +// mehrteilige Transaktionen nicht mitmacht. +// +// `SUPABASE_DB_URL` heisst nach dem Umzug auf einen eigenen Container nicht +// mehr, was es ist — der Name bleibt als Rückfallebene, damit bestehende +// .env-Dateien und Arbeitsstationen weiterlaufen. +const verbindung = process.env.MIGRATE_DATABASE_URL || process.env.SUPABASE_DB_URL; if (!verbindung) { console.error( - "SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" + - "nicht der Transaktions-Pooler aus DATABASE_URL." + "MIGRATE_DATABASE_URL fehlt. Erwartet wird der **direkte** Zugang als Verwalter,\n" + + "nicht die Verbindung der Anwendung aus DATABASE_URL." ); process.exit(1); } diff --git a/scripts/umzug-von-supabase.sh b/scripts/umzug-von-supabase.sh new file mode 100644 index 0000000..aa8061f --- /dev/null +++ b/scripts/umzug-von-supabase.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Holt den Datenbestand von Supabase und spielt ihn in den db-Container. +# +# ═══ Der Ablauf, und warum er so aussieht ═══ +# +# Das **Schema** kommt nicht aus dem Abzug, sondern aus den Migrationen: +# scripts/migrate.mjs baut es im Container von Grund auf. Nachgewiesen ist, +# dass dabei Spalte für Spalte, Index für Index, Policy für Policy dasselbe +# herauskommt wie in der Produktion. Der Weg über die Migrationen hat zwei +# Vorteile gegenüber einem Schema-Abzug: die Buchführung stimmt danach von +# selbst, und was Supabase an eigenen Rechten und Eigentümern hineingeschrieben +# hat, kommt gar nicht erst mit. +# +# Übernommen werden nur die **Daten**, mit --disable-triggers: sonst stolpert +# jede Fremdschlüsselprüfung über die Ladereihenfolge. (RLS steht dem nicht im +# Weg — keine der 19 Tabellen hat FORCE ROW LEVEL SECURITY, der Eigentümer +# schreibt also durch.) +# +# Auf dem Host wird **nur Docker** gebraucht: psql und pg_dump kommen aus dem +# Dienst `psql`, Node aus dem Dienst `migrate` — beide im Profil `tools`, das +# bei einem gewöhnlichen `docker compose up` nicht mitläuft. +# +# Aufruf im Deploy-Verzeichnis: +# +# SUPABASE_DB_URL='postgresql://…@…supabase.com:5432/postgres' \ +# ./scripts/umzug-von-supabase.sh +# +# Der Abzug bleibt als Datei liegen und wird nicht gelöscht: geht das +# Einspielen schief, ist der Bestand sonst nur noch bei Supabase. + +set -euo pipefail + +: "${SUPABASE_DB_URL:?SUPABASE_DB_URL fehlt — der direkte Zugang zur alten Datenbank (Supabase: Port 5432)}" + +ABZUG="${ABZUG:-supabase-daten-$(date +%Y%m%d-%H%M%S).sql}" + +echo "── 1/5 Datenbank starten und abwarten" +docker compose up -d db +docker compose ps db + +echo +echo "── 2/5 Schema aus den Migrationen aufbauen" +docker compose run --rm migrate + +echo +echo "── 3/5 Daten von Supabase abziehen → ${ABZUG}" +# Läuft im psql-Abbild, damit pg_dump nicht auf dem Host installiert sein muss. +# --entrypoint überschreibt den psql-Aufruf des Dienstes. +docker compose run --rm --no-deps --entrypoint pg_dump psql \ + "${SUPABASE_DB_URL}" \ + --data-only \ + --disable-triggers \ + --schema=public \ + --no-owner \ + --no-privileges \ + > "${ABZUG}" +echo " $(wc -l < "${ABZUG}") Zeilen, $(du -h "${ABZUG}" | cut -f1)" + +echo +echo "── 4/5 Einspielen" +docker compose run --rm -T psql < "${ABZUG}" + +echo +echo "── 5/5 Gegenprobe — Zeilenzahlen hier und dort" +echo " neue Datenbank:" +docker compose run --rm -T psql -qAt -c " + select ' '||table_name||': '||(xpath('/row/c/text()', + query_to_xml('select count(*) as c from public.'||quote_ident(table_name), false, true, '')))[1]::text + from information_schema.tables + where table_schema='public' and table_type='BASE TABLE' order by table_name" + +echo +echo "Fertig. Nächster Schritt: DATABASE_URL in .env auf den Container zeigen" +echo "lassen (siehe DEPLOYMENT.md), dann docker compose up -d --build" +echo +echo "Der Abzug bleibt unter ${ABZUG} liegen — erst löschen, wenn die Anwendung" +echo "gegen die neue Datenbank nachweislich läuft." diff --git a/supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql b/supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql new file mode 100644 index 0000000..ccbe18c --- /dev/null +++ b/supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql @@ -0,0 +1,59 @@ +-- Das RLS-Sicherheitsnetz gehört ins Repository, nicht nur in die Datenbank. +-- +-- `rls_auto_enable()` und der Ereignis-Trigger `ensure_rls` schalten Row Level +-- Security bei jeder neu angelegten Tabelle in `public` sofort ein. Sie sind +-- der Grund, warum eine vergessene Policy nichts preisgibt: ohne RLS wäre eine +-- neue Tabelle für die Anwendungsrolle offen, mit RLS und ohne Policy ist sie +-- leer. Ein Fehler wird so zu einer fehlenden Anzeige statt zu einem Leck. +-- +-- **Gefunden beim Umzug von Supabase auf einen eigenen Container.** Beide +-- Objekte existierten nur in der laufenden Datenbank — angelegt von Hand, in +-- keiner Migration. Ein Nachbau aus den Migrationen (die CI tut das bei jedem +-- Lauf, und ein neuer Server sowieso) hätte das Netz stillschweigend nicht +-- gehabt: alles funktioniert, nur die nächste neue Tabelle wäre ungeschützt. +-- +-- Der Text ist der aus der Produktion, unverändert übernommen. Auf einer +-- Datenbank, die ihn schon hat, ändert diese Migration nichts. + +create or replace function public.rls_auto_enable() +returns event_trigger +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + cmd record; +begin + for cmd in + select * + from pg_event_trigger_ddl_commands() + where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') + and object_type in ('table', 'partitioned table') + loop + if cmd.schema_name is not null and cmd.schema_name in ('public') then + begin + execute format('alter table if exists %s enable row level security', cmd.object_identity); + raise log 'rls_auto_enable: enabled RLS on %', cmd.object_identity; + exception + -- Bewusst geschluckt: ein Ereignis-Trigger, der wirft, lässt die + -- ganze DDL scheitern. Eine Tabelle, an der das Einschalten nicht + -- klappt, ist ein Fall fürs Protokoll — kein Grund, die Migration + -- abzubrechen, die sie gerade anlegt. + when others then + raise log 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity; + end; + else + raise log 'rls_auto_enable: skip % (Schema %)', cmd.object_identity, cmd.schema_name; + end if; + end loop; +end; +$function$; + +-- `create event trigger` kennt kein `if not exists`; deshalb erst weg, dann neu. +-- Auf einer Datenbank ohne den Trigger ist das `drop` folgenlos. +drop event trigger if exists ensure_rls; + +create event trigger ensure_rls + on ddl_command_end + when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') + execute function public.rls_auto_enable();