Files
alpenwerk-hr/docs/entra-sso.md
Maximilian Stubhan 730521ee79 Keep the environment's identifiers out of the repository
Projekt-Ref, Entra-Client- und Tenant-ID standen im Klartext in der
SSO-Anleitung. Geheimnisse sind das nicht — ohne Schlüssel gibt eine
Projekt-URL nichts her, und RLS greift unabhängig davon. Sie zeigen aber auf
die laufende Umgebung, und dieses Repository wandert weiter als sie: es geht
gleich auf einen eigenen Git-Server und später an den Kunden.

Jetzt Platzhalter; die Werte gehören in die Übergabedokumentation. In der
Historie stehen sie weiterhin — das sauber zu entfernen hiesse, die Historie
neu zu schreiben, und das passiert nicht nebenbei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:39:20 +02:00

122 lines
5.0 KiB
Markdown

# 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://<projekt-ref>.supabase.co/auth/v1/callback` |
| Anwendungs-ID (Client) | `<client-id>` |
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
Die konkreten Werte stehen bewusst nicht hier, sondern in der
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt
eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die
echte Umgebung, und dieses Repository wandert weiter als sie.
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 | `<client-id>` |
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
| Azure Tenant URL | `https://login.microsoftonline.com/<tenant-id>` |
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.