Files
alpenwerk-hr/docs/azure-migration.md
Maximilian Stubhan c2366e3408 Make the cut-over script safe to paste, and record the Azure design
The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor
the transaction boundaries are not ours to assume, and a mapping table that
vanished between the two inserts would leave positions without assignments
and be miserable to diagnose. It is now dropped explicitly once both inserts
have run.

docs/azure-migration.md is the design for the Azure move, for review before
any code changes.

Its main finding corrects what I said when I laid out the options: I claimed
that dropping Supabase would push the security boundary into application
code. It does not. auth.uid() appears 70 times, but only one of them matters
— inside is_hr_user(), which all 58 policies call. Swapping the source of
the user id there leaves every policy valid, so the database stays the
boundary.

The risk moves elsewhere, and the design says so plainly: the user id
arrives via set_config(..., true), which is transaction-local. Outside a
transaction it sticks to the pooled connection, and the next request on that
connection runs as the previous user. So the plan makes that structurally
impossible — a single access function that owns the transaction, a lint rule
against importing the pool anywhere else, a database role without BYPASSRLS
so a missing context returns nothing rather than everything, and a test that
sends two requests over one pooled connection to prove the second cannot see
the first.
2026-07-27 14:39:57 +02:00

5.3 KiB

Umstieg auf Azure — Sicherheitsarchitektur

Entwurf zur Abnahme. Noch kein Code umgestellt.

Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID, Supabase als Abhängigkeit entfernt.

Ausgangslage, gemessen

Anzahl
RLS-Policies 58
auth.uid() / auth.users in Migrationen 79
Fremdschlüssel auf auth.users 9
Datenzugriffe in der App (.from(), .rpc()) 50
Dateien mit Supabase-Import 10

Die Anwendung läuft mit dem anon-Key (lib/supabase/server.ts, client.ts); der Service-Role-Key kommt nur in lib/supabase/admin.ts vor. Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der Proxy und nicht der Anwendungscode.

Der entscheidende Befund

auth.uid() erscheint 70-mal, aber für die Absicherung zählt genau eine Stelle:

create or replace function is_hr_user() returns boolean
language sql security definer stable as $$
  select exists (
    select 1 from profiles p
    where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
  );
$$;

Alle 58 Policies rufen is_hr_user() auf. Wird hier die Herkunft der Benutzerkennung ausgetauscht, bleiben alle Policies unverändert gültig. Die Sicherheitsarchitektur wandert also nicht in den Anwendungscode — das war meine Sorge bei Variante B, und sie ist ausgeräumt.

Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (insert into audit_log values (auth.uid(), …)) und sind eine mechanische Ersetzung.

Zielarchitektur

1. Benutzertabelle statt auth.users

create table app_users (
  id uuid primary key default gen_random_uuid(),
  entra_object_id uuid not null unique,   -- oid aus dem Entra-Token
  email text not null,
  created_at timestamptz not null default now()
);

Die neun Fremdschlüssel zeigen künftig hierauf. profiles.id bleibt der Schlüssel, an dem role und is_active hängen — die HR-Freischaltung funktioniert unverändert.

2. Sitzungskontext statt auth.uid()

create or replace function current_app_user() returns uuid
language sql stable as $$
  select nullif(current_setting('app.user_id', true), '')::uuid;
$$;

auth.uid()current_app_user(), überall. is_hr_user() bleibt sonst Wort für Wort gleich.

3. Der kritische Punkt: wie der Kontext gesetzt wird

Hier entscheidet sich, ob die Migration sicher ist.

Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst set local app.user_id ausführt:

await db.transaction(async (tx) => {
  await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
  return tx.select();
});

Das dritte Argument true bedeutet transaktionslokal. Ohne Transaktion bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage, die dieselbe Verbindung aus dem Pool zieht, liefe mit der Kennung des vorherigen Benutzers. Das ist genau die Art Fehler, die in einem Test nie auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.

Deshalb: kein direkter Zugriff auf den Pool. Es gibt eine einzige Zugriffsfunktion, die die Transaktion und set_config erzwingt, und eine Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet. Das muss strukturell unmöglich sein, nicht per Konvention.

Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle ohne BYPASSRLS. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts zurück — statt alles.

4. Anmeldung

Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung des Tokens wird die oid auf app_users.entra_object_id abgebildet; existiert kein Eintrag, wird einer angelegt — ohne profiles-Zeile, also ohne Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute (is_active ist per Vorgabe false).

Damit erledigt sich die SSO-Frage aus der IT-Liste mit.

5. Datenzugriff

PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs .rpc()-Aufrufe sind trivial (direkter Funktionsaufruf), die 44 .from()-Aufrufe sind Query-Builder-Umschreibungen.

Das handgeschriebene lib/supabase/types.ts entfällt: Drizzle erzeugt die Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.

Was bewusst gleich bleibt

  • Alle 58 RLS-Policies, unverändert
  • Das gesamte Schema samt Enums, Arrays, jsonb, PL/pgSQL, partiellen Indizes
  • pgcrypto und pg_trgm (beide auf Azure freigegeben)
  • Die Geschäftslogik in den RPCs

Reihenfolge

  1. app_users, current_app_user(), Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
  2. Zugriffsschicht mit erzwungener Transaktion + set_config, plus Test, der den Kontextverlust nachweist
  3. Entra-ID-Anmeldung
  4. Die 50 Datenzugriffe umstellen
  5. Supabase-Pakete entfernen
  6. Umzug der Datenbank per pg_dump/pg_restore

Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und prüft, dass die zweite die erste nicht sieht.

Offene Fragen an die Kunden-IT

  • Welcher Entra-Mandant, und wer legt die App-Registrierung an?
  • Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über profiles.is_active?
  • Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
  • Wer betreibt und patcht?