Maximilian Stubhan ee1a38492c Pin search_path, and close a write path that needed no login
Der Security Advisor meldete 34 Warnungen; nach dem Festnageln des
search_path sind es zehn. Von diesen zehn ist eine einzige ein echter Befund
— aber die hätte man in den 34 nicht gesehen.

search_path (34 Warnungen)

Alle betroffenen Funktionen sind SECURITY INVOKER, laufen also mit den
Rechten der aufrufenden Person; ein manipulierter Pfad bringt dort nichts zu
holen. Die vier DEFINER-Funktionen setzen ihn längst. Festgenagelt wird es
trotzdem, für den Tag, an dem jemand eine davon auf SECURITY DEFINER
umstellt, weil eine Mutation an RLS vorbei schreiben muss — dann wäre es eine
Rechteausweitung, und an den search_path denkt in dem Moment niemand.

Als Schleife statt als Liste von 34 Signaturen: die würde beim nächsten
Umbau veralten. Sie lässt Erweiterungen in Ruhe (pg_trgm legt show_trgm und
show_limit ebenfalls in public ab) und prüft am Ende selbst nach. pg_temp
steht ausdrücklich am Pfadende — ohne die Angabe durchsucht Postgres das
temporäre Schema zuerst, und dort darf jede Sitzung anlegen, was sie will.

Ausführungsrechte (8 Warnungen)

Hier trennt sich der Befund vom Rauschen, und zwar durch Messen mit dem
anon-Schlüssel gegen die laufende Datenbank:

  anon.rpc(is_hr_user)                -> false
  anon.rpc(current_hr_user_id)        -> null
  anon.rpc(apply_due_pending_changes) -> 0

Die ersten beiden bleiben offen, und das ist keine Nachlässigkeit: sie werden
aus den RLS-Policies heraus aufgerufen, und ein Policy-Ausdruck wird mit den
Rechten der abfragenden Rolle ausgewertet. Ohne EXECUTE scheitert jede
Abfrage auf jeder Tabelle. Preisgegeben wird nichts — beide nehmen keine
Argumente und beantworten nur eine Frage über die aufrufende Person selbst.

Der dritte ist der Befund. apply_due_pending_changes() wendet vorgemerkte
Versetzungen, Beförderungen und Abwesenheiten an, ist SECURITY DEFINER,
umgeht damit RLS — und war ohne Anmeldung aufrufbar. Der anon-Schlüssel steht
im ausgelieferten Browser-Bündel. Der Schaden wäre begrenzt, weil nur ohnehin
fällige Änderungen angewandt werden, aber es ist ein Schreibpfad für Fremde
und macht das Geheimnis der Cron-Route wirkungslos. Entzogen für anon und
authenticated; die Route benutzt die service_role und läuft weiter.

rls_auto_enable() stammt nicht aus diesen Migrationen und wird nirgends
aufgerufen. Der Entzug ist risikolos und beantwortet die Frage, was sie tut,
notfalls mit einer klaren Fehlermeldung.

Zwei Warnungen bleiben bewusst stehen

pg_trgm in public trägt die Operatorklasse gin_trgm_ops, auf der zwei
GIN-Indizes auf employees liegen. Ein Schemawechsel müsste Indizes und jeden
search_path mitziehen — Risiko für eine Konvention, keine Rechteausweitung.

„Leaked Password Protection" ist gegenstandslos: die Passwort-Anmeldung ist
abgeschaltet, eine Anmeldung gegen die API antwortet mit
email_provider_disabled. Es gibt kein Passwort, das kompromittiert sein
könnte.
2026-07-28 11:54:02 +02:00

Manner HR Master

Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur (Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/ Beförderungen/Karenz, Reorganisationen und der zugehörige Audit-Trail.

Zweck

Die App ersetzt Excel-basierte HR-Stammdatenpflege durch ein Werkzeug mit verbindlichen Regeln (z. B. wirksame Daten statt sofortiger Änderungen, eindeutige Positions-/Org-Nummern, verpflichtende Historie) und einem lückenlosen Audit-Trail für jede Änderung.

Alle Mitarbeiterdaten in diesem System sind vertraulich — Stammdaten, Verträge, Sozialversicherungsnummern, Angehörige und Audit-Daten. Zugriff ist auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe Sicherheitsprinzipien).

Tech-Stack

  • Next.js 16 (App Router) — Achtung: Next.js 16 hat Breaking Changes gegenüber älteren Versionen (u. a. proxy.ts statt middleware.ts). Vor Änderungen an Framework-nahen Dateien die lokalen Docs unter node_modules/next/dist/docs/ konsultieren; sie sind maßgeblich, ältere Anleitungen im Netz beschreiben teils überholte APIs.
  • React 19, TypeScript
  • Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in Supabase, nicht im Next.js-Prozess.
  • Tailwind CSS v4
  • Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests (gegen ein lokales Supabase)

Setup

npm install
cp .env.example .env.local   # Werte eintragen, siehe unten
npm run dev

Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):

supabase start   # startet lokalen Postgres/Auth/Studio-Stack

supabase/config.toml und .env.test.local sind bereits auf die Standard-Ports der lokalen Supabase-CLI abgestimmt.

Umgebungsvariablen

Siehe .env.example für die vollständige, kommentierte 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
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).

Scripts

Befehl Zweck
npm run dev Lokaler Dev-Server
npm run build Produktions-Build
npm run start Produktions-Server (nach build)
npm run lint ESLint (eslint-config-next, Flat Config)
npm run typecheck tsc --noEmit
npm run test Vitest, Unit-Tests (tests/unit/**)
npm run test:integration Vitest gegen eine echte (lokale) Supabase-Instanz — braucht supabase start und .env.test.local
npm run test:e2e Playwright
npm run check lint + typecheck + test + build in Folge

Sicherheitsprinzipien

  • RLS ist die eigentliche Schranke, nicht die UI. Jede Tabelle hat Row Level Security aktiv; proxy.ts (App-Ebene) ist Defense-in-Depth, keine Ersatzkontrolle.
  • 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.
  • 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).
  • 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 docs/security-review.md.
  • Historie ist append-only (employee_history, audit_log) — RLS erlaubt kein update/delete. Korrekturen sind kompensierende Einträge.

Cron-Konfiguration

/api/cron/apply-pending-changes wendet wirksam gewordene, zukunftsdatierte Änderungen an (pending_org_changesapply_due_pending_changes()).

  • Auf Vercel: vercel.json definiert den täglichen Schedule; Vercel Cron sendet Authorization: Bearer <CRON_SECRET> automatisch, wenn CRON_SECRET in den Projekt-Env-Vars gesetzt ist.
  • Außerhalb von Vercel (Docker): kein Vercel Cron verfügbar — siehe DEPLOYMENT.md für den Cron-Sidecar-Container, der denselben Endpoint mit demselben Schema aufruft.
  • Fehlt CRON_SECRET oder stimmt der Header nicht, antwortet die Route mit 401 (nicht 500 — bewusst, siehe tests/unit/security.test.ts).

Supabase-Hinweise

  • Schema-Quelle der Wahrheit: supabase/migrations/. Menschlich lesbare Zusammenfassung: docs/data-model.md.
  • Migrationen einspielen: supabase db push (gegen das verlinkte Projekt) bzw. supabase start + automatische Anwendung für lokale Entwicklung.
  • supabase/seed.ts und .env.test.local sind nur für lokale Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.

Testing

  • npm run test — schnell, keine externen Abhängigkeiten, läuft in CI.
  • npm run test:integration — braucht eine laufende lokale Supabase-Instanz (supabase start) und .env.test.local; prüft RLS-Verhalten end-to-end (siehe tests/integration/authorization.test.ts für das HR-Only-Zugriffs- modell).
  • npm run test:e2e — Playwright gegen einen laufenden Dev-/Preview-Server.

Deployment

Siehe 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).

Known TODOs vor Produktivbetrieb

  • Content-Security-Policy fehlt noch (next.config.ts setzt bewusst keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den Supabase-Client stillschweigend zu brechen).
  • Lokale Scratch-Artefakte (.scratch_*, .scratch_shots/) enthalten Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und liegen noch im Arbeitsverzeichnis. Sie sind jetzt über .gitignore ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und bei Bedarf gelöscht werden.
  • Kein granulareres Rollenmodell — aktuell HR-only (alles-oder-nichts). Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die Erweiterung in eine neue Migration (is_hr_user()/RLS-Policies), nicht in App-seitigen Code.
Description
No description provided
Readme 938 KiB
Languages
TypeScript 67.3%
PLpgSQL 31.2%
JavaScript 0.8%
CSS 0.5%
Dockerfile 0.2%