Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".
Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.
Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.
Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.
Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:
- Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
withUser() — und das öffnet immer eine Transaktion.
- set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
- Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
erzeugt zwei Fehler.
- Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.
tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.
Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.
Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
|
||||
-- eigener Sitzungskontext.
|
||||
--
|
||||
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
|
||||
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
|
||||
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
|
||||
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
|
||||
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
|
||||
--
|
||||
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
|
||||
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
|
||||
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
|
||||
-- bewegt, lässt sich nicht testen.
|
||||
|
||||
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
|
||||
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
|
||||
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
|
||||
--
|
||||
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
|
||||
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
|
||||
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
|
||||
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
|
||||
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
|
||||
create or replace function app_current_user_id()
|
||||
returns uuid
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
|
||||
-- fehlt die Variable, kommt null statt eines Fehlers.
|
||||
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
||||
if v_id is not null then
|
||||
return v_id;
|
||||
end if;
|
||||
|
||||
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
|
||||
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
|
||||
-- auth.users.
|
||||
begin
|
||||
execute 'select auth.uid()' into v_id;
|
||||
exception
|
||||
when undefined_function or invalid_schema_name or undefined_table then
|
||||
v_id := null;
|
||||
end;
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function app_current_user_id() is
|
||||
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
|
||||
|
||||
grant execute on function app_current_user_id() to anon, authenticated, service_role;
|
||||
|
||||
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
|
||||
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
|
||||
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
|
||||
create table if not exists app_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
|
||||
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
|
||||
external_id text not null unique,
|
||||
email text not null,
|
||||
full_name text,
|
||||
created_at timestamptz not null default now(),
|
||||
last_seen_at timestamptz
|
||||
);
|
||||
|
||||
comment on table app_users is
|
||||
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
|
||||
|
||||
create index if not exists app_users_email_idx on app_users (lower(email));
|
||||
|
||||
alter table app_users enable row level security;
|
||||
|
||||
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
|
||||
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
|
||||
drop policy if exists "app_users_select_own" on app_users;
|
||||
create policy "app_users_select_own" on app_users
|
||||
for select using (id = app_current_user_id() or is_hr_user());
|
||||
|
||||
grant select on table app_users to anon, authenticated;
|
||||
grant all on table app_users to service_role;
|
||||
|
||||
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
|
||||
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
|
||||
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
|
||||
-- weiterhin is_hr_user() auf und merken davon nichts.
|
||||
create or replace function is_hr_user()
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from profiles p
|
||||
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function current_hr_user_id()
|
||||
returns uuid
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
stable
|
||||
as $$
|
||||
select p.id from profiles p
|
||||
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
|
||||
$$;
|
||||
|
||||
create or replace function current_actor_name()
|
||||
returns text
|
||||
language sql
|
||||
stable
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
select coalesce(p.full_name, p.email, 'Unbekannt')
|
||||
from profiles p where p.id = app_current_user_id();
|
||||
$$;
|
||||
|
||||
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
|
||||
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
|
||||
drop policy if exists "profiles_select_own" on profiles;
|
||||
create policy "profiles_select_own" on profiles
|
||||
for select using (app_current_user_id() = id);
|
||||
|
||||
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
|
||||
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
|
||||
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
||||
create policy "hire_drafts_owner" on hire_drafts
|
||||
for all
|
||||
using (created_by = app_current_user_id() and is_hr_user())
|
||||
with check (created_by = app_current_user_id() and is_hr_user());
|
||||
|
||||
drop policy if exists "saved_reports_owner" on saved_reports;
|
||||
create policy "saved_reports_owner" on saved_reports
|
||||
for all
|
||||
using (created_by = app_current_user_id() and is_hr_user())
|
||||
with check (created_by = app_current_user_id() and is_hr_user());
|
||||
|
||||
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
|
||||
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
|
||||
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
|
||||
-- Datenbank öffnet.
|
||||
do $$
|
||||
begin
|
||||
perform set_config('app.user_id', '', true);
|
||||
if is_hr_user() then
|
||||
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
|
||||
end if;
|
||||
|
||||
perform set_config('app.user_id', gen_random_uuid()::text, true);
|
||||
if is_hr_user() then
|
||||
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
|
||||
end if;
|
||||
|
||||
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
|
||||
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
|
||||
perform set_config('app.user_id', '', true);
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user