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:
148
docs/azure-migration.md
Normal file
148
docs/azure-migration.md
Normal 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?
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user