Run the database in a container of our own
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 <noreply@anthropic.com>
This commit is contained in:
47
.env.example
47
.env.example
@@ -1,23 +1,42 @@
|
|||||||
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
|
# ── Datenbank ────────────────────────────────────────────────────────
|
||||||
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
|
#
|
||||||
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
|
# 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
|
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
||||||
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
||||||
#
|
# `alpenwerk_app` wird in deploy/db-init genau so angelegt.
|
||||||
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
|
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
|
||||||
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
|
# Im Compose-Netz läuft Postgres ohne TLS — die Verbindung verlässt den
|
||||||
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
|
# Server nicht. Bei einer Datenbank ausserhalb diesen Wert entfernen.
|
||||||
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
|
DATABASE_SSL=false
|
||||||
# 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=
|
|
||||||
# Verbindungen im Pool; Vorgabe 10.
|
# Verbindungen im Pool; Vorgabe 10.
|
||||||
DATABASE_POOL_MAX=
|
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) ─────────────────────────
|
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
||||||
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
||||||
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
||||||
|
|||||||
16
.github/workflows/deploy.yml
vendored
16
.github/workflows/deploy.yml
vendored
@@ -10,9 +10,21 @@ name: Deploy
|
|||||||
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
|
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
|
||||||
# git.elycon.solutions.)
|
# 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:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
|
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
|
||||||
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
|
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
|
||||||
|
|||||||
@@ -69,12 +69,18 @@ einem eigenen Linux-Server läuft.
|
|||||||
|
|
||||||
## Was wird containerisiert – und was nicht
|
## Was wird containerisiert – und was nicht
|
||||||
|
|
||||||
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
|
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
|
||||||
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
|
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
|
||||||
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
|
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
|
||||||
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
|
- **Nicht mehr Supabase.** Die Anwendung sprach ohnehin unmittelbar mit
|
||||||
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
|
PostgreSQL — Supabase war nur der Betreiber. Was die Plattform beisteuerte,
|
||||||
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
|
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
|
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||||||
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
||||||
keinen Anmeldedienst, der mit ausgerollt werden müsste.
|
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`.
|
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
|
||||||
Dasselbe Abbild läuft damit in Test und Produktion.
|
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:<passwort>@<projekt>.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:<APP_DB_PASSWORD>@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
|
## 4. Reverse Proxy + HTTPS
|
||||||
|
|
||||||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||||||
|
|||||||
82
deploy/db-init/00-rollen-und-erweiterungen.sql
Normal file
82
deploy/db-init/00-rollen-und-erweiterungen.sql
Normal file
@@ -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;
|
||||||
56
deploy/db-init/01-auth-attrappe.sql
Normal file
56
deploy/db-init/01-auth-attrappe.sql
Normal file
@@ -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;
|
||||||
@@ -1,4 +1,86 @@
|
|||||||
services:
|
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:
|
app:
|
||||||
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
||||||
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
||||||
@@ -6,6 +88,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
|
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
|
||||||
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
|
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
|
||||||
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
|
# 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
|
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
|
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:
|
||||||
|
|||||||
@@ -42,15 +42,20 @@ const argumente = new Set(process.argv.slice(2));
|
|||||||
const nurZeigen = argumente.has("--dry-run");
|
const nurZeigen = argumente.has("--dry-run");
|
||||||
const baseline = argumente.has("--baseline");
|
const baseline = argumente.has("--baseline");
|
||||||
|
|
||||||
// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus).
|
// Der **direkte** Zugang als Verwalter — nicht DATABASE_URL.
|
||||||
// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543),
|
//
|
||||||
// und der verträgt kein `create schema` in einer Transaktion mit mehreren
|
// DATABASE_URL gehört der Anwendungsrolle, und die darf bewusst kein Schema
|
||||||
// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung.
|
// ändern; ausserdem zeigte sie bei Supabase auf den Transaktions-Pooler, der
|
||||||
const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL;
|
// 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) {
|
if (!verbindung) {
|
||||||
console.error(
|
console.error(
|
||||||
"SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" +
|
"MIGRATE_DATABASE_URL fehlt. Erwartet wird der **direkte** Zugang als Verwalter,\n" +
|
||||||
"nicht der Transaktions-Pooler aus DATABASE_URL."
|
"nicht die Verbindung der Anwendung aus DATABASE_URL."
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
77
scripts/umzug-von-supabase.sh
Normal file
77
scripts/umzug-von-supabase.sh
Normal file
@@ -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."
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user