Files
alpenwerk-hr/docs/security-review.md
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

3.2 KiB

Sicherheitsprüfung

Der Stand vom Juli 2026 ist überholt

Die damalige Prüfung untersuchte einen Dienstschlüssel, der den Zeilenschutz aushebelte, einen Cookie-Adapter der abgelösten Plattform und ein Anmeldemodell über auth.users. Alle drei Bestandteile gibt es nicht mehr. Ihre Bewertungen stehen deshalb nicht mehr hier — ein Befund über eine Datei, die entfernt wurde, sagt nichts über den heutigen Zustand, verleitet aber dazu, ihn für geprüft zu halten.

Was nachweislich weg ist:

Damals geprüft Heute
SUPABASE_SERVICE_ROLE_KEY Ersatzlos entfallen. Es gibt keinen Zugang, der den Zeilenschutz umgeht.
Postgres-Rolle service_role Nur noch eine Platzhalterrolle ohne Anmelderecht, damit alte Migrationen abspielbar bleiben.
Cookie-Adapter der Plattform Ersetzt durch Auth.js gegen Entra ID.
auth.users Ersetzt durch app_users; kein Fremdschlüssel zeigt mehr nach auth.
Browser-Anbindung an die Datenbank Entfallen. Der Browser spricht ausschließlich mit dieser Anwendung.

Eine neue Prüfung des heutigen Aufbaus steht aus. Bis dahin gilt: geprüft ist, was unten steht, weil Tests es abdecken — nicht das Übrige.

Was strukturell gilt und durch Tests abgedeckt ist

Der Zeilenschutz ist die Schranke, nicht die Oberfläche. Jede Tabelle hat ihn aktiv, dazu 67 Regeln. Ein Sicherheitsnetz in der Datenbank schaltet ihn für neu angelegte Tabellen selbsttätig ein, damit eine vergessene Tabelle nicht offen steht.

Es gibt genau einen Weg an die Datenbank. withUser() setzt den Sitzungskontext in derselben Transaktion wie die Abfrage. Die Verbindung wird nicht ausgeleitet, und eine Linter-Regel verbietet den direkten Import des Treibers ausserhalb von lib/db/. Der Nachweis dazu ist tests/integration/session-context.test.ts: ohne Kontext liefert die Berechtigungsfunktion nie wahr, und eine Kennung leckt nicht über den Verbindungspool in die nächste Anfrage.

Der Nachweis entsteht in der Datenbank, nicht im Anwendungscode. Jede ändernde SQL-Funktion schreibt ihren Protokolleintrag in derselben Transaktion wie die Änderung. Ein fehlgeschlagener Eintrag lässt den ganzen Vorgang scheitern.

Warum es keinen lib/audit/audit-log.ts gibt

Ein anwendungsseitiger Protokollschreiber wäre eine zweite Quelle neben der bestehenden — und die einzige, die sich umgehen liesse, indem jemand die Datenbankfunktion direkt aufruft. Die Aufgabenstellung nennt die Tabellen audit_logs und planned_changes; im Schema heissen sie audit_log (Einzahl) und pending_org_changes. Die vollständige Zuordnung steht im Datenkatalog.

Der nächtliche Lauf

Die Route prüft Authorization über request.headers.get("authorization"). HTTP-Kopfzeilen sind unabhängig von Gross- und Kleinschreibung, und Headers.get() behandelt sie entsprechend. Drei Tests decken das ab: fehlende Kopfzeile, falscher Wert, nicht gesetztes CRON_SECRET. Die Antwort ist jeweils 401, nicht 500.

Offen

  • Neue Prüfung des heutigen Aufbaus — Entra ID, app_users, withUser(), der nächtliche Lauf ohne Sonderrechte.
  • Inhaltsrichtlinie scharf schalten — sie läuft im Nur-Bericht-Modus, bis die Meldungen sauber sind.
  • Zugangsdaten rotieren — siehe README.