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>
6.3 KiB
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:
- Zertifikate & Geheimnisse → neues Client-Geheimnis. Der Wert wird gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
- API-Berechtigungen →
openid,profile,email(Microsoft Graph, delegiert), Administratorzustimmung erteilen.User.Readwird nicht gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das Profilbild aus dem Graph zu holen —lib/auth/config.tsschaltet beides ab. - 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
- Auth.js prüft das Token von Entra (
state,nonce, Signatur, Aussteller). - Der Rückruf in
auth.tsnimmt daraus dieoid— nichtsub, nicht die E-Mail. Dieoididentifiziert dieselbe Person über Anwendungen hinweg und überlebt Namens- und Adressänderungen. app_upsert_user(oid, email, name)legt dieapp_users-Zeile an und liefert die Kennung, die von da an in jeder Transaktion alsapp.user_idsteht.- 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:
- SSO in Betrieb nehmen, einmal anmelden.
- Im
jwt-Rückruf einmaligconsole.log(profile)— das zeigt die Ansprüche so, wie der Mandant sie tatsächlich schickt. - 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.