# 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: ```sql 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` ```sql 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()` ```sql 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: ```ts 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?