diff --git a/docs/azure-migration.md b/docs/azure-migration.md new file mode 100644 index 0000000..c8b6d2b --- /dev/null +++ b/docs/azure-migration.md @@ -0,0 +1,148 @@ +# 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? diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql index 1dc0467..22db557 100644 --- a/supabase/migrations/20260727120200_om_cutover.sql +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -70,11 +70,14 @@ on conflict (title) do nothing; -- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen -- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen -- doch verschieden. +-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten +-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle +-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt. create temporary table om_pos_map ( employee_id uuid primary key, position_id uuid not null default gen_random_uuid(), seq bigint -) on commit drop; +); insert into om_pos_map (employee_id, seq) select id, row_number() over (order by org_level, personnel_number) from employees; @@ -154,6 +157,8 @@ begin end if; end $$; +drop table om_pos_map; + -- ═══ 4. Altmodell entfernen ══════════════════════════════════════ -- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind