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.
This commit is contained in:
2026-07-27 14:39:57 +02:00
parent cce5c6b0ce
commit c2366e3408
2 changed files with 154 additions and 1 deletions

148
docs/azure-migration.md Normal file
View File

@@ -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?

View File

@@ -70,11 +70,14 @@ on conflict (title) do nothing;
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen -- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen -- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
-- doch verschieden. -- 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 ( create temporary table om_pos_map (
employee_id uuid primary key, employee_id uuid primary key,
position_id uuid not null default gen_random_uuid(), position_id uuid not null default gen_random_uuid(),
seq bigint seq bigint
) on commit drop; );
insert into om_pos_map (employee_id, seq) insert into om_pos_map (employee_id, seq)
select id, row_number() over (order by org_level, personnel_number) from employees; select id, row_number() over (order by org_level, personnel_number) from employees;
@@ -154,6 +157,8 @@ begin
end if; end if;
end $$; end $$;
drop table om_pos_map;
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════ -- ═══ 4. Altmodell entfernen ══════════════════════════════════════
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind -- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind