Sign in with Entra ID, and give the login screen something to look at
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: 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.
Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.
Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.
Zwei Werkzeuge für die Umstellung:
- relink-profile.ts hängt eine bestehende profiles-Zeile auf die
Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
stünde in der Historie eine Kennung ohne Konto dahinter.
- entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
Anspruch heisst und aussieht, und das unterscheidet sich je nach
Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
sonst wäre er geraten.
Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.
Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.
Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
This commit is contained in:
@@ -1,20 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter
|
||||
// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg
|
||||
// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor,
|
||||
// bedingten Zugriff, Sperrung beim Austritt.
|
||||
//
|
||||
// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
|
||||
// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
|
||||
// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
|
||||
|
||||
export async function signInWithEntra() {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
// Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen,
|
||||
// damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase
|
||||
// nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts
|
||||
// steht — ein untergeschobener Host läuft also ins Leere.
|
||||
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
|
||||
|
||||
redirect("/");
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "azure",
|
||||
options: {
|
||||
// openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
|
||||
// Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
|
||||
scopes: "openid profile email",
|
||||
redirectTo: `${origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !data.url) redirect("/login?error=sso_failed");
|
||||
redirect(data.url);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { login, logout } from "@/actions/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS } from "@/components/ui/Field";
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen.
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
|
||||
// für die Fehlertexte, die Entra im Rückweg mitschickt.
|
||||
const ERROR_MESSAGES = {
|
||||
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
||||
no_hr_access: {
|
||||
title: "Kein HR-Zugriff",
|
||||
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
},
|
||||
sso_failed: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -23,56 +29,105 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const error = code ? ERROR_MESSAGES[code] : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
||||
// im ersten Moment unter dem Faltenrand sitzt.
|
||||
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
||||
<BrandPanel />
|
||||
|
||||
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="lg:hidden">
|
||||
<Wordmark className="text-ink" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
||||
<p className="text-sm font-bold text-danger-text">{error.title}</p>
|
||||
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
<form action={logout} className="mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
|
||||
>
|
||||
Abmelden und mit einem anderen Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" fullWidth className="mt-2">
|
||||
Anmelden
|
||||
</Button>
|
||||
<form action={signInWithEntra} className="mt-7">
|
||||
<EntraSignInButton />
|
||||
</form>
|
||||
|
||||
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
|
||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
||||
Personaldaten verschlossen.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
|
||||
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
|
||||
ohne von der einen Schaltfläche gegenüber abzulenken. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
|
||||
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
|
||||
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
|
||||
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
|
||||
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Wordmark className="text-white" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
Stichtag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
|
||||
drei Einheiten. */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
|
||||
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
|
||||
</svg>
|
||||
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/auth/callback/route.ts
Normal file
30
app/auth/callback/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
|
||||
// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
|
||||
// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
|
||||
// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
|
||||
// zurück auf /login.
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams, origin } = request.nextUrl;
|
||||
|
||||
// Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
|
||||
// zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
|
||||
// stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
|
||||
if (searchParams.get("error")) {
|
||||
return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
|
||||
// Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
|
||||
// proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
|
||||
// davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
|
||||
return NextResponse.redirect(`${origin}/`);
|
||||
}
|
||||
32
components/auth/EntraSignInButton.tsx
Normal file
32
components/auth/EntraSignInButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
|
||||
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
|
||||
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
|
||||
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
|
||||
// fremde Anmeldeseite springt.
|
||||
export function EntraSignInButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
|
||||
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||
</svg>
|
||||
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
117
docs/entra-sso.md
Normal file
117
docs/entra-sso.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 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://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` |
|
||||
| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
|
||||
| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` |
|
||||
|
||||
Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier.
|
||||
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 | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
|
||||
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
|
||||
| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` |
|
||||
|
||||
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.
|
||||
5
proxy.ts
5
proxy.ts
@@ -38,6 +38,11 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||
|
||||
// Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
|
||||
// sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
|
||||
// nach /login um und die Anmeldung kommt nie zustande.
|
||||
if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
|
||||
|
||||
if (!user) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
|
||||
70
supabase/entra-claims.ts
Normal file
70
supabase/entra-claims.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/entra-claims.ts <e-mail>
|
||||
//
|
||||
// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
|
||||
// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
|
||||
// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
|
||||
// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
|
||||
// wäre er geraten.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts <e-mail>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (matches.length === 0) {
|
||||
console.error(`Kein Konto zu ${email}. Vorhanden:`);
|
||||
for (const u of data.users) console.error(` ${u.email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
|
||||
// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
|
||||
for (const user of matches) {
|
||||
console.log(`\n── ${user.email} ──`);
|
||||
console.log(` id: ${user.id}`);
|
||||
console.log(` erstellt: ${user.created_at}`);
|
||||
console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
|
||||
|
||||
for (const identity of user.identities ?? []) {
|
||||
console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
|
||||
console.log(
|
||||
Object.entries(identity.identity_data ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
// Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
|
||||
// die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
|
||||
// das unbrauchbar.
|
||||
console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
|
||||
console.log(
|
||||
Object.entries(user.user_metadata ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
|
||||
console.log(`\n── profiles zu ${email} ──`);
|
||||
for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
|
||||
if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");
|
||||
110
supabase/relink-profile.ts
Normal file
110
supabase/relink-profile.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
|
||||
// Person um.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]
|
||||
//
|
||||
// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
|
||||
// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
|
||||
// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
|
||||
// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
|
||||
//
|
||||
// Ohne --apply wird nur angezeigt, was passieren würde.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
const apply = process.argv.includes("--apply");
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
|
||||
// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
|
||||
const REFERENCES: { table: string; column: string }[] = [
|
||||
{ table: "audit_log", column: "actor_user_id" },
|
||||
{ table: "employee_notes", column: "author_user_id" },
|
||||
{ table: "employee_notes", column: "done_by" },
|
||||
{ table: "hire_drafts", column: "created_by" },
|
||||
{ table: "saved_reports", column: "created_by" },
|
||||
{ table: "pending_org_changes", column: "created_by" },
|
||||
{ table: "profiles", column: "created_by" },
|
||||
];
|
||||
|
||||
const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
|
||||
const alt = accounts.find((u) => u.id !== entra?.id);
|
||||
|
||||
if (!entra) {
|
||||
console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!alt) {
|
||||
console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
|
||||
if (!profile) {
|
||||
console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
|
||||
console.log(`neu: ${entra.id} (azure)`);
|
||||
console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
|
||||
|
||||
if (!apply) {
|
||||
console.log("\nTrockenlauf. Mit --apply ausführen.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
|
||||
// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
|
||||
// HR-Konto, und niemand könnte eines mehr freischalten.
|
||||
const { error: insertError } = await supabase.from("profiles").insert({
|
||||
...profile,
|
||||
id: entra.id,
|
||||
email: entra.email ?? profile.email,
|
||||
});
|
||||
if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
|
||||
console.log("profiles-Zeile für das Entra-Konto angelegt.");
|
||||
|
||||
for (const ref of REFERENCES) {
|
||||
const { error: updateError, count } = await supabase
|
||||
.from(ref.table)
|
||||
.update({ [ref.column]: entra.id }, { count: "exact" })
|
||||
.eq(ref.column, alt.id);
|
||||
if (updateError) {
|
||||
console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
|
||||
}
|
||||
|
||||
const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
|
||||
if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
|
||||
|
||||
const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
|
||||
if (deleteUserError) {
|
||||
// Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
|
||||
// umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
|
||||
console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
|
||||
} else {
|
||||
console.log("Altes Konto gelöscht.");
|
||||
}
|
||||
|
||||
console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");
|
||||
Reference in New Issue
Block a user