Files
alpenwerk-hr/supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql
Maximilian Stubhan 77d9a95f7f
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s
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>
2026-08-20 16:34:08 +02:00

60 lines
2.5 KiB
PL/PgSQL

-- 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();