Sign in with Entra ID, and give the login screen something to look at

Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: auth.uid() liefert weiterhin eine UUID, profiles.id
trägt weiterhin role und is_active, und damit bleiben is_hr_user() und alle
58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in
den Anwendungscode.

Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.

Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.

Zwei Werkzeuge für die Umstellung:

  - relink-profile.ts hängt eine bestehende profiles-Zeile auf die
    Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
    Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
    zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
    sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
    stünde in der Historie eine Kennung ohne Konto dahinter.
  - entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
    Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
    Anspruch heisst und aussieht, und das unterscheidet sich je nach
    Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
    sonst wäre er geraten.

Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.

Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.

Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
This commit is contained in:
2026-07-27 20:34:00 +02:00
parent 27669e0359
commit 2cce101c4b
8 changed files with 499 additions and 61 deletions

117
docs/entra-sso.md Normal file
View File

@@ -0,0 +1,117 @@
# Anmeldung über Entra ID
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
Sicherheitsgrenze wandert nicht in den Anwendungscode.
## Einrichtung im Entra-Mandanten
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
| | |
|---|---|
| Name | Alpenwerk HR |
| Kontotypen | Nur ein Mandant |
| Umleitungs-URI (Web) | `https://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` |
| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` |
Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier.
Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
„Secret Value" und in keine Datei im Projekt.
Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
Supabase-Projekts.
Danach:
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
2. **API-Berechtigungen**`openid`, `profile`, `email` (Microsoft Graph,
delegiert), Administratorzustimmung erteilen.
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
## Einrichtung in Supabase
Authentication → Providers → Azure:
| Feld | Wert |
|---|---|
| Application (Client) ID | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` |
Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
Registrierung nur den eigenen Mandanten akzeptiert.
Authentication → URL Configuration:
- Site URL: die Produktions-URL
- Redirect URLs: `http://localhost:3000/auth/callback` und
`https://<produktion>/auth/callback`
## Freischaltung über die Entra-Gruppe
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
### Woher der Gruppen-Anspruch kommt
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
ist. Zwei Varianten:
| Variante | Lizenz | Haken |
|---|---|---|
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
Das ist kein Detail, sondern der Kern der Absicherung.
`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
des Anbieters. Nur das ist eine belastbare Quelle.
### Reihenfolge
Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
ankommt — das hängt an der gewählten Variante und an der Konfiguration des
Mandanten. Ablauf:
1. SSO in Betrieb nehmen, einmal anmelden.
2. `node --env-file=.env.local supabase/entra-claims.ts <e-mail>` zeigt, was in
`identity_data` gelandet ist.
3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
Ohne Schritt 2 wäre die Migration geraten.
### Was die Gruppe nicht kann
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
## Bestehende Zugänge
Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
zeigt sie ins Leere und die Person ist ausgesperrt.
`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
zu der es kein Konto mehr gibt.