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>
138 lines
6.3 KiB
Markdown
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.
|