Files
alpenwerk-hr/docs/entra-sso.md
Maximilian Stubhan 578ce696f0 Correct the policy count in the places that quote it
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>
2026-08-13 17:33:54 +02:00

138 lines
6.3 KiB
Markdown

# Anmeldung über Entra ID
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
keinen Anmeldedienst eines Anbieters mehr dazwischen.
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
damit bleiben `is_hr_user()` und alle 21 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://<produktion>/api/auth/callback/microsoft-entra-id` |
| 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 Client-Geheimnis
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
Umgebung, und dieses Repository wandert weiter als sie.
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
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. `User.Read` wird **nicht**
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
## Konfiguration der Anwendung
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
| Variable | Wert |
|---|---|
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
aber die Eingangstür soll erst gar nicht so weit offenstehen.
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
Ernstfall genau das gewünschte Mittel.
## Was bei der ersten Anmeldung passiert
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
überlebt Namens- und Adressänderungen.
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
Entwürfe und Protokolleinträge hängen an dieser ID.
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
behauptet, hat sie bereits bewiesen.
## 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.
### 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. |
### Wo die Auswertung hingehört
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
`profile.groups` aus dem ID-Token vor.
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
selbstbedienbar gewesen.)
### Reihenfolge
Gebaut wird das erst, 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. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
wie der Mandant sie tatsächlich schickt.
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
### 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.
## Wer prüft was
| Stelle | Prüft | Wann |
|---|---|---|
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.