Six comments and doc lines put the number of RLS policies at 58. It is
21 — counted from pg_policy while building the data catalogue. The
figure appears in load-bearing prose ("all 58 policies call
is_hr_user()", "all 58 policies stay unchanged"), where being wrong by a
factor of three invites someone to go looking for the missing thirty-
seven.
The two occurrences inside supabase/migrations/ stay as they are. That
file already ran against the database; its comments record what was
believed at the time, and editing them would make the file differ from
what was applied for no gain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
5.3 KiB
Markdown
149 lines
5.3 KiB
Markdown
# 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 | 21 |
|
|
| `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 21 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 21 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?
|