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.
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 pgcryptoundpg_trgm(beide auf Azure freigegeben)- Die Geschäftslogik in den RPCs
Reihenfolge
app_users,current_app_user(), Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar- Zugriffsschicht mit erzwungener Transaktion +
set_config, plus Test, der den Kontextverlust nachweist - Entra-ID-Anmeldung
- Die 50 Datenzugriffe umstellen
- Supabase-Pakete entfernen
- 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?