diff --git a/.env.example b/.env.example index c516bf8..62e070f 100644 --- a/.env.example +++ b/.env.example @@ -10,15 +10,25 @@ DATABASE_SSL= # Verbindungen im Pool; Vorgabe 10. DATABASE_POOL_MAX= -# Public: safe to expose to the browser (inlined into the client bundle at -# build time). Anon-key access is still fully gated by RLS server-side. -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +# ── Anmeldung (Auth.js + Microsoft Entra ID) ───────────────────────── +# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird. +# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel +# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist. +AUTH_SECRET= -# Server-only: bypasses Row Level Security entirely. Never prefix with -# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by -# `import "server-only"`), never log or return in an API response. -SUPABASE_SERVICE_ROLE_KEY= +# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client), +# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant). +# +# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich +# jedes Microsoft-Konto anmelden, auch ein privates. +AUTH_MICROSOFT_ENTRA_ID_ID= +AUTH_MICROSOFT_ENTRA_ID_SECRET= +AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com//v2.0 + +# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer +# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut +# Auth.js die Rückruf-Adresse aus den Request-Headern. +AUTH_URL= # Shared secret Vercel Cron sends as `Authorization: Bearer ` when it # calls /api/cron/apply-pending-changes (set the same value in the Vercel diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3406a30..f47312d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -37,21 +37,22 @@ Werte eintragen: | Variable | Woher | |---|---| -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) | +| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben | +| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft | +| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com//v2.0` | | `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` | +Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md). + Wichtig zum Verständnis: -- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle - eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich - diese Werte, muss das Image **neu gebaut** werden – ein reiner Container- - Neustart reicht nicht. -- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets. - Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im - Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über - `env_file` injiziert. +- **Nichts davon wird in das Image eingebacken.** Es gibt keine + `NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit + über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen + neuen Build — und dasselbe Image läuft in Test und Produktion. - `.env` steht schon in `.gitignore` – nicht committen. ## 2. Bauen und lokal testen @@ -145,12 +146,19 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig. ## Troubleshooting -- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche - `NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese - Werte sind eingebacken). +- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der + Entra-Registrierung muss exakt + `https:///api/auth/callback/microsoft-entra-id` lauten. Steht die + Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie + selbst sieht, zusätzlich `AUTH_URL` setzen. +- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die + Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine + `profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben + Kennung, die in `app_users` steht. - **Cron läuft nicht:** `docker compose logs cron` – prüft, ob `/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in `.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`). -- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche - Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`, - Server-Komponenten). +- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL` + fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim + ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im + Start-Log. diff --git a/Dockerfile b/Dockerfile index 73ed13c..55c36a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# Public env vars are inlined into the client bundle at build time, so they -# must be available here, not just at runtime. Values are passed in via -# --build-arg (see DEPLOYMENT.md). -ARG NEXT_PUBLIC_SUPABASE_URL -ARG NEXT_PUBLIC_SUPABASE_ANON_KEY -ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL -ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY +# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in +# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen +# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit. +# +# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und +# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und +# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt. ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/README.md b/README.md index dbb1467..b2b30ca 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,18 @@ Liste. Kurzfassung: | Variable | Sichtbarkeit | Zweck | |---|---|---| -| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden | -| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen | +| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben | +| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS | +| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` | | `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` | -`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken — -eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant -für Docker-Deployments, siehe unten). +**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das +Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung +selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, +überall dasselbe — vorher brauchte jede Umgebung ihr eigenes. ## Scripts @@ -85,10 +89,15 @@ für Docker-Deployments, siehe unten). - **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`, geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem — siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell). -- **Service-Role-Key ist server-only.** Einzige Verwendung: - `lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht - einen versehentlichen Client-Import zu einem Build-Fehler statt einem - Laufzeitproblem). +- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS + aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt + dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss, + steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort + selbst, was es tut. +- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.** + Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank + ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den + Import von `pg` ausserhalb von `lib/db/`. - **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die Änderung selbst. Details und Prüfung siehe diff --git a/actions/auth.ts b/actions/auth.ts index 9ce36c3..8dbda3d 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,43 +1,22 @@ "use server"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { signIn, signOut } from "@/auth"; -// 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. +// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen +// Passwort-Pfad: 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. +// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js +// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben +// Host. Ein untergeschobener Host läuft also weiterhin ins Leere. export async function signInWithEntra() { - const supabase = await createClient(); - - // 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"; - - 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); + // Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in + // Next.js. + await signIn("microsoft-entra-id", { redirectTo: "/" }); } export async function logout() { - const supabase = await createClient(); - await supabase.auth.signOut(); - redirect("/login"); + await signOut({ redirectTo: "/login" }); } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f7c1275..95608d3 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -14,10 +14,11 @@ export default async function AppLayout({ children }: { children: ReactNode }) { // Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt — // und damit nebenbei auf einem einheitlichen Lesestand. const data = await withUser(userId, async (tx) => { - // Defense in depth: proxy.ts already redirects any non-active-HR session - // away before this layout ever renders. Re-checking here means a gap in - // the proxy matcher (or a future route added outside it) still fails - // closed instead of silently granting access — see docs/security.md. + // Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang. + // Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine + // Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine + // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten + // Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS. const profile = await tx .selectFrom("profiles") .select(["full_name", "email", "role", "is_active"]) @@ -40,7 +41,10 @@ export default async function AppLayout({ children }: { children: ReactNode }) { return { profile, openPositions, locations, drafts, openNotes }; }); - if (!data) redirect("/login"); + // `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet + // ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen + // Anmeldeseite und versuchte es endlos erneut. + if (!data) redirect("/login?error=no_hr_access"); const userLabel = data.profile.full_name || data.profile.email || ""; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 189da87..1571a6f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -25,7 +25,17 @@ type LoginPageProps = { export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams; - const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null; + // Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine + // Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen + // Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne + // diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre + // nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter + // selbst kommt nie auf die Seite. + const code = params.error + ? Object.hasOwn(ERROR_MESSAGES, params.error) + ? (params.error as ErrorCode) + : "sso_failed" + : null; const error = code ? ERROR_MESSAGES[code] : null; return ( diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..11359e7 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,9 @@ +import { handlers } from "@/auth"; + +// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung. +// +// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des +// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und +// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der +// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md. +export const { GET, POST } = handlers; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts deleted file mode 100644 index 2772fd0..0000000 --- a/app/auth/callback/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -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}/`); -} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..e59762c --- /dev/null +++ b/auth.ts @@ -0,0 +1,63 @@ +import "server-only"; +import NextAuth from "next-auth"; +import { authConfig } from "@/lib/auth/config"; +import { asSystem, sql } from "@/lib/db"; + +// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt. +// +// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt. +// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der +// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung +// versteht. + +/** + * Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung, + * die überall sonst als `userId` durchgereicht wird. + * + * Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine: + * app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen + * Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag + * hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte. + */ +async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise { + const row = await asSystem(async (tx) => { + const result = await sql<{ id: string }>` + select app_upsert_user(${externalId}, ${email}, ${fullName}) as id + `.execute(tx); + return result.rows[0]; + }); + + if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung."); + return row.id; +} + +export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ + ...authConfig(), + callbacks: { + async jwt({ token, profile }) { + // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra + // vor. Danach wird das Token nur noch weitergereicht — die Datenbank + // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. + if (!profile) return token; + + const externalId = typeof profile.oid === "string" ? profile.oid : null; + const email = [profile.email, profile.preferred_username, profile.upn].find( + (v): v is string => typeof v === "string" && v.length > 0 + ); + + // Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme + // als `null` bei withUser() an, und die Policies gäben dann konsequent + // nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als + // Anmeldefehler. + if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse."); + + token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null); + return token; + }, + + async session({ session, token }) { + if (token.uid) session.user.id = token.uid; + return session; + }, + }, +})); diff --git a/docker-compose.yml b/docker-compose.yml index d394920..be90fcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ services: app: + # Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL, + # AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede + # Umgebung dasselbe. build: context: . - args: - # NEXT_PUBLIC_* vars are inlined into the browser bundle at build - # time, so they have to be passed here, not just in env_file below. - NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} restart: unless-stopped ports: - "3000:3000" diff --git a/docs/entra-sso.md b/docs/entra-sso.md index 74ea932..74b2e14 100644 --- a/docs/entra-sso.md +++ b/docs/entra-sso.md @@ -1,10 +1,11 @@ # 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. +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. -**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 +**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 58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in den Anwendungscode. @@ -16,55 +17,69 @@ App-Registrierung, einmalig — angelegt im Mandanten *loudspring management Gmb |---|---| | Name | Alpenwerk HR | | Kontotypen | Nur ein Mandant | -| Umleitungs-URI (Web) | `https://.supabase.co/auth/v1/callback` | +| Umleitungs-URI (Web) | `https:///api/auth/callback/microsoft-entra-id` | | Anwendungs-ID (Client) | `` | | Verzeichnis-ID (Mandant) | `` | 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. +Ü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. -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. +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. + 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. -## Einrichtung in Supabase +## Konfiguration der Anwendung -Authentication → Providers → Azure: +Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle: -| Feld | Wert | +| Variable | Wert | |---|---| -| Application (Client) ID | `` | -| Secret Value | der Wert aus „Zertifikate & Geheimnisse" | -| Azure Tenant URL | `https://login.microsoftonline.com/` | +| `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//v2.0` | -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. +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. -Authentication → URL Configuration: +`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im +Ernstfall genau das gewünschte Mittel. -- Site URL: die Produktions-URL -- Redirect URLs: `http://localhost:3000/auth/callback` und - `https:///auth/callback` +## 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. Diese Zeile -entsteht aus der Mitgliedschaft in einer Entra-Gruppe. +`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. ### Woher der Gruppen-Anspruch kommt @@ -76,46 +91,47 @@ ist. Zwei Varianten: | 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` +### Wo die Auswertung hingehört -Das ist kein Detail, sondern der Kern der Absicherung. +In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt +`profile.groups` aus dem ID-Token vor. -`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. +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 -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: +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. `node --env-file=.env.local supabase/entra-claims.ts ` zeigt, was in - `identity_data` gelandet ist. -3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben. +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 die Migration geraten. +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. +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 +## Wer prüft was -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. +| 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 | -`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. +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. diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..92fac77 --- /dev/null +++ b/lib/auth/config.ts @@ -0,0 +1,99 @@ +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; +import type { NextAuthConfig } from "next-auth"; + +// Der Teil der Anmeldung, der **ohne Datenbank** auskommt. +// +// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer +// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung. +// Würde der Proxy die vollständige Konfiguration laden, zöge er die +// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb +// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt, +// steht in auth.ts. + +/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */ +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag + +/** + * Der Aussteller — mit Abbruch statt Rückfall. + * + * Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend + * `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten + * Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach + * `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes** + * Microsoft-Konto anmelden dürfen, auch ein privates. + * + * Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert + * ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung + * muss deshalb den Start verhindern, nicht die Tür aufmachen. + * + * In der Entwicklung bleibt es bei einer Warnung: dort ist nichts + * konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die + * Anmeldeseite mit. + */ +function tenantIssuer(): string | undefined { + const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER; + if (issuer) return issuer; + + const hinweis = + "AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " + + "Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen."; + if (process.env.NODE_ENV === "production") throw new Error(hinweis); + console.warn(`[auth] ${hinweis}`); + return undefined; +} + +/** + * Die Konfiguration als **Funktion**, nicht als Objekt. + * + * Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil + * tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die + * Prüfung schon beim Import — und `next build` importiert jedes Route-Modul, + * um die Seitendaten einzusammeln. Der Bau bräuchte dann die + * Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht + * mehr einmal bauen und überall ausliefern. + */ +export function authConfig(): NextAuthConfig { + return { + providers: [ + MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: tenantIssuer(), + + // Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt + // damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine + // Berechtigung, die niemand braucht, muss die Mandantenverwaltung + // trotzdem genehmigen — und das Bild landete base64-kodiert im + // Sitzungscookie, das dann in Teile zerfällt. + authorization: { params: { scope: "openid profile email" } }, + profile(profile) { + return { + // Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die + // `oid` identifiziert dieselbe Person über Anwendungen hinweg und + // überlebt Namens- und Adressänderungen. + id: profile.oid, + name: profile.name ?? null, + // `email` ist im Token ein optionaler Anspruch — je nach Mandant + // fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe + // Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau + // den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert + // haben. + email: profile.email ?? profile.preferred_username ?? profile.upn ?? null, + image: null, + }; + }, + }), + ], + + // Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die + // erste Seite, die jemand sieht, und soll aussehen wie die Anwendung. + pages: { signIn: "/login", error: "/login" }, + + session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS }, + + // Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem + // Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil + // es die Herkunft nicht bestätigen kann. + trustHost: true, + }; +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 6c348bb..c764640 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,23 +1,21 @@ import "server-only"; -import { createClient } from "@/lib/supabase/server"; +import { auth } from "@/auth"; // Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt. // -// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere -// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht, -// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht -// an fünfzig Aufrufstellen. +// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war +// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles +// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter. // -// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist -// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also -// bereits auf das, was app_current_user_id() in der Datenbank erwartet. +// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung +// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie +// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id. +// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in +// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts. export async function currentUserId(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - return user?.id ?? null; + const session = await auth(); + return session?.user?.id ?? null; } /** diff --git a/lib/db/index.ts b/lib/db/index.ts index 1c21838..c5975b4 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,6 +1,6 @@ import "server-only"; import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; -import { pool } from "./pool"; +import { getPool } from "./pool"; import type { Schema } from "./schema"; // Der einzige Weg an die Datenbank. @@ -33,8 +33,11 @@ import type { Schema } from "./schema"; // BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts // zurück — nicht alles. +// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely +// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses +// Moduls noch keine Zugangsdaten — siehe getPool(). const db = new Kysely({ - dialect: new PostgresDialect({ pool }), + dialect: new PostgresDialect({ pool: async () => getPool() }), }); export type Tx = Transaction; diff --git a/lib/db/pool.ts b/lib/db/pool.ts index 583b05a..c71aeb9 100644 --- a/lib/db/pool.ts +++ b/lib/db/pool.ts @@ -10,30 +10,46 @@ import { Pool } from "pg"; // die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den // Import von `pg` und von dieser Datei überall sonst. -const connectionString = process.env.DATABASE_URL; -if (!connectionString) { - throw new Error( - "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + - "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." - ); +let instance: Pool | undefined; + +/** + * Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import. + * + * Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route- + * Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte + * schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich + * in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen. + */ +export function getPool(): Pool { + if (instance) return instance; + + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + + "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." + ); + } + + instance = new Pool({ + connectionString, + // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder + // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank + // zulässt bringen nur Wartezeit an einer anderen Stelle. + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt + // eine Verbindung zu belegen. + statement_timeout: 20_000, + idle_in_transaction_session_timeout: 20_000, + connectionTimeoutMillis: 10_000, + // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. + ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, + }); + + // Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. + instance.on("error", (err) => { + console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); + }); + + return instance; } - -export const pool = new Pool({ - connectionString, - // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder - // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank - // zulässt bringen nur Wartezeit an einer anderen Stelle. - max: Number(process.env.DATABASE_POOL_MAX ?? 10), - // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt - // eine Verbindung zu belegen. - statement_timeout: 20_000, - idle_in_transaction_session_timeout: 20_000, - connectionTimeoutMillis: 10_000, - // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. - ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, -}); - -// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. -pool.on("error", (err) => { - console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); -}); diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts deleted file mode 100644 index e546941..0000000 --- a/lib/supabase/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createBrowserClient } from "@supabase/ssr"; -import type { Database } from "./types"; - -// For use in Client Components that need interactivity (filters, live -// hints, etc). Server Components/Actions should use lib/supabase/server.ts. -// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser. -export function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - return createBrowserClient(supabaseUrl, supabaseAnonKey); -} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts deleted file mode 100644 index 60494a0..0000000 --- a/lib/supabase/server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import "server-only"; -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; -import type { Database } from "./types"; - -// For use in Server Components and Server Actions. Respects the signed-in -// user's session, so all reads/writes go through RLS as that user. Uses only -// the anon key (never the service role key) — the user's own session cookie -// is what determines access, via RLS. -export async function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - const cookieStore = await cookies(); - - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // Called from a Server Component during render — safe to ignore - // because proxy.ts refreshes the session cookie on every request. - } - }, - }, - }); -} diff --git a/next.config.ts b/next.config.ts index 17ee181..2c459e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,25 +2,28 @@ import type { NextConfig } from "next"; // Report-only rather than enforcing, deliberately: the policy is derived from // what this app is known to load — its own bundle, the self-hosted Nunito -// files from next/font, and the Supabase project from -// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is -// worth more than a guessed one that blanks the app for every HR user. -// Promote it to `Content-Security-Policy` once the reports come back clean. +// files from next/font, and nothing else — but an unenforced policy that logs +// violations is worth more than a guessed one that blanks the app for every +// HR user. Promote it to `Content-Security-Policy` once the reports come back +// clean. // // 'unsafe-inline' on script-src is not laziness: Next.js inlines its // bootstrap and hydration payload as inline