diff --git a/.env.example b/.env.example index 25dcbd3..72b4b8a 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,9 @@ AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/` when it -# calls /api/cron/apply-pending-changes (set the same value in the Vercel -# project's env vars). Generate with e.g. `openssl rand -hex 32`. +# Gemeinsames Geheimnis, mit dem sich der nächtliche Lauf ausweist: der +# `cron`-Container schickt es als `Authorization: Bearer ` an +# /api/cron/apply-pending-changes. Ohne angemeldete Person gibt es keine +# Sitzung, an der die Route den Aufruf prüfen könnte. +# Erzeugen mit: openssl rand -hex 32 CRON_SECRET= diff --git a/.gitignore b/.gitignore index 29805da..e8ed22a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,9 +34,6 @@ yarn-error.log* .env* !.env.example -# vercel -.vercel - # typescript *.tsbuildinfo next-env.d.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index b6f2c89..6793be1 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,71 +1,14 @@ # Deployment -Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine -Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also -jederzeit möglich. +Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server, +zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt +keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit +aus `.env`. -| | passt, wenn | -|---|---| -| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg | -| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll | - -**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen), -die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach -ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe -[docs/entra-sso.md](docs/entra-sso.md)). - -## Vercel - -Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen -Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses -Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten: - -### a) Von der Arbeitsstation ausrollen (ohne GitHub) - -```bash -npx vercel login -npx vercel link -npx vercel --prod -``` - -Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen -bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei -Personen ist das eher Vorteil als Nachteil. - -### b) Zusätzlich nach GitHub spiegeln - -```bash -git remote add github git@github.com:/alpenwerk-hr.git -git push github feat/sap-om-org-model -``` - -Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push -aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden. - -### Danach - -1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment - Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist - nicht nötig, Vercel setzt den Host selbst. -2. **Umleitungs-URI** in der Entra-Registrierung ergänzen: - `https://.vercel.app/api/auth/callback/microsoft-entra-id` -3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet. - -Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind: -`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und -`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen -Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit -vielen tausend Zeilen ansteht. - -Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL` -auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären -die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen -verbraucht. - -## Deployment mit Docker - -Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf -einem eigenen Linux-Server läuft. +Zu klären, bevor es losgeht: die Umgebungsvariablen aus +[Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der +Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung +eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)). ## Was wird containerisiert – und was nicht @@ -84,12 +27,10 @@ einem eigenen Linux-Server läuft. - **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt keinen Anmeldedienst, der mit ausgerollt werden müsste. -- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr, - ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/ - Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von - Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml` - enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema - und demselben Bearer-Secret, das die Route bereits erwartet. +- **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00 + Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen, + Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird + der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person. ## Betrieb im Firmennetz — zwei Dinge vorab @@ -103,12 +44,13 @@ nichts, ausgehend zwingend: | Ziel | Wofür | Ohne das | |---|---|---| | `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich | -| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts | Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem -abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL- -Instanz im selben Netz statt Supabase. +abgeschotteten Netz, braucht es dafür einen Proxy. + +Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im +selben Compose-Netz. Nach aussen geht dafür nichts. **2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für `localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein @@ -126,11 +68,9 @@ bei zwanzig nicht. ## Voraussetzungen -- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine - enthaltene `docker compose`) auf dem Zielserver. -- Ein bestehendes Supabase-Projekt mit den Migrationen aus - `supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie - bisher). +- Docker + Docker Compose (v2, das in Docker Engine enthaltene + `docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql; + was gebraucht wird, kommt aus Containern. ## 1. `.env` anlegen @@ -142,8 +82,10 @@ Werte eintragen: | Variable | Woher | |---|---| -| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) | -| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft | +| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters | +| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` | +| `DATABASE_URL` | `postgresql://alpenwerk_app:@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an | +| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht | | `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!) | diff --git a/README.md b/README.md index 6ae6955..e639917 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, `/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`). -- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron - sendet `Authorization: Bearer ` automatisch, wenn - `CRON_SECRET` in den Projekt-Env-Vars gesetzt ist. -- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe - [`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der - denselben Endpoint mit demselben Schema aufruft. +- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich + 03:00 Uhr, mit `Authorization: Bearer `. - Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit `401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`). @@ -140,10 +136,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, ## Deployment -Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment -(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates). -Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im -Vercel-Projekt setzen (siehe oben). +Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container, +Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates. ## Known TODOs vor Produktivbetrieb diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 84f675a..28cb4a0 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -21,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string { } // Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and -// an unpinned formatter renders it in the *server's* zone — UTC in Docker and -// on Vercel — so every entry would read an hour or two early for the people +// an unpinned formatter renders it in the *server's* zone — UTC in the +// container — so every entry would read an hour or two early for the people // the log is for. const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", { day: "2-digit", diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index e3bc0fd..34f241f 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -4,10 +4,11 @@ import { callFunction } from "@/lib/db/rpc"; // Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten // ändern with a future "Wirksam ab" date) once their date has arrived — see -// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron -// job (see vercel.json), not on behalf of any HR user, so it authenticates -// via a shared secret rather than a Supabase session and uses the -// service-role client (the one legitimate server-only use case for it). +// apply_due_pending_changes() in supabase/migrations. +// +// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00. +// Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb +// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET). export async function GET(request: NextRequest) { const authHeader = request.headers.get("authorization"); if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) { diff --git a/app/api/import/route.ts b/app/api/import/route.ts index ee88a57..7e754f5 100644 --- a/app/api/import/route.ts +++ b/app/api/import/route.ts @@ -25,10 +25,10 @@ class Rueckabwicklung extends Error { } } -// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist — -// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server -// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300 -// heben, falls eine Datei mit vielen tausend Zeilen ansteht. +// Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an +// serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt +// als Absichtserklärung stehen: ein Import, der länger als eine Minute +// braucht, ist einer, der in Teilen laufen sollte. export const maxDuration = 60; type Antwort = { diff --git a/docker-compose.yml b/docker-compose.yml index bbbff84..c94dd03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,9 +100,10 @@ services: env_file: - .env - # Replaces the Vercel Cron job from vercel.json (not available outside - # Vercel): calls the same endpoint on the same daily schedule using the - # same bearer-secret auth the route already expects. + # Der nächtliche Lauf: ruft täglich um 03:00 Uhr + # /api/cron/apply-pending-changes auf, damit fällig gewordene Versetzungen, + # Beförderungen, Karenzen und Reorganisationen wirksam werden. Ausgewiesen + # über CRON_SECRET — es gibt dabei keine angemeldete Person. cron: image: alpine:3.20 restart: unless-stopped diff --git a/docs/data-model.md b/docs/data-model.md index 8850934..46d367d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -99,10 +99,9 @@ Ein einziges Rollenmodell, kein Mehrfach-Rollen-System: Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist (`revoke ... from public, anon, authenticated; grant ... to service_role`). Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen — -täglich per Vercel Cron (`vercel.json`), außerhalb von Vercel per -Ersatz-Scheduler (siehe `DEPLOYMENT.md`, Docker-Cron-Sidecar). Die Route -selbst authentifiziert per `CRON_SECRET`-Bearer-Token, nicht per -Supabase-Session — es gibt keine anfragende Person, nur den Scheduler. +täglich vom `cron`-Container aus `docker-compose.yml`. Die Route selbst +authentifiziert per `CRON_SECRET`-Bearer-Token, nicht über eine Sitzung — es +gibt keine anfragende Person, nur den Zeitplan. Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder `cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf diff --git a/lib/format.ts b/lib/format.ts index 4fe62a8..a7ab0b5 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,7 +1,7 @@ // This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as // timestamptz, and is used from a single timezone. Every conversion here is // pinned to Europe/Vienna rather than the runtime's zone: the server renders -// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned +// in UTC (im Container) while the browser renders in Vienna, so an unpinned // formatter produces a different day on each side — wrong dates for the user // near midnight, and a React hydration mismatch. const TIMEZONE = "Europe/Vienna"; diff --git a/next.config.ts b/next.config.ts index 26231ff..854f9ba 100644 --- a/next.config.ts +++ b/next.config.ts @@ -46,10 +46,9 @@ const nextConfig: NextConfig = { // Emits a self-contained .next/standalone server (only the deps actually // used at runtime, no full node_modules) — what the Dockerfile copies in. // - // Auf Vercel ist das falsch: dort baut die Plattform selbst und erwartet - // die übliche Ausgabe. `VERCEL` setzt sie in jeder Baustrecke, die Angabe - // entfällt dort also von selbst — und der Docker-Weg bleibt unberührt. - output: process.env.VERCEL ? undefined : "standalone", + // Ohne Ausnahme: die Anwendung läuft in einem Container auf einem eigenen + // Server, und dort ist das der richtige Ausgabemodus. + output: "standalone", // Baseline security headers (clickjacking, MIME-sniffing, referrer leakage, // browser feature access) plus the report-only CSP described above. async headers() { diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 43aadcb..0000000 --- a/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "crons": [ - { - "path": "/api/cron/apply-pending-changes", - "schedule": "0 3 * * *" - } - ] -}