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>
122 lines
5.0 KiB
Markdown
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.
|