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>
This commit is contained in:
@@ -1,113 +1,68 @@
|
||||
# Security Review
|
||||
# Sicherheitsprüfung
|
||||
|
||||
Ergebnis eines gezielten Greps über das gesamte Repository (ohne
|
||||
`node_modules`) nach neun sicherheitsrelevanten Mustern, mit Bewertung im
|
||||
jeweiligen Kontext. Stand: 2026-07-24.
|
||||
## Der Stand vom Juli 2026 ist überholt
|
||||
|
||||
## `SUPABASE_SERVICE_ROLE_KEY`
|
||||
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.
|
||||
|
||||
Referenziert in vier Dateien, alle server-seitig / lokal:
|
||||
Was nachweislich weg ist:
|
||||
|
||||
- `lib/supabase/admin.ts` — die einzige App-Laufzeit-Verwendung, hinter
|
||||
`import "server-only"`. Jetzt mit expliziter Fehlermeldung bei fehlendem
|
||||
Wert statt eines `!`-Non-null-Assertions (siehe Änderungen).
|
||||
- `supabase/seed.ts`, `tests/integration/helpers.ts` — Node-Skripte
|
||||
außerhalb des Next.js-Bundles (Seeding bzw. Test-Setup), lesen den Wert
|
||||
nur aus `process.env`. Unkritisch.
|
||||
- `.scratch_make_test_hr.mjs` — lokales, nicht eingechecktes
|
||||
Hilfsskript (liest den Key ebenfalls nur aus `process.env`, kein
|
||||
Hardcoding). War bisher weder committet noch von `.gitignore` erfasst;
|
||||
**behoben** — `.gitignore` schließt `.scratch_*` jetzt explizit aus, damit
|
||||
ein künftiges `git add -A` es nicht versehentlich eincheckt.
|
||||
| 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. |
|
||||
|
||||
Keine Fundstelle exponiert den Key im Browser-Bundle oder in einer
|
||||
API-Response.
|
||||
**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.
|
||||
|
||||
## `service_role` (Postgres-Rolle)
|
||||
## Was strukturell gilt und durch Tests abgedeckt ist
|
||||
|
||||
9 Treffer, ausschließlich in `supabase/migrations/*.sql` — Standard-Supabase-
|
||||
Muster:
|
||||
**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.
|
||||
|
||||
- `20260714120500_default_grants.sql`: explizite `grant ... to anon,
|
||||
authenticated, service_role` (nötig, weil RLS Objekt-Rechte nur
|
||||
einschränkt, nicht ersetzt — ohne dieses Grant schlägt jede Query auch
|
||||
mit korrekter Policy mit "permission denied" fehl).
|
||||
- `20260714120200_effective_dating_rpcs.sql` / `20260714120600_...`:
|
||||
`revoke execute ... from public, anon, authenticated; grant execute ...
|
||||
to service_role` für `apply_due_pending_changes()` — das ist die
|
||||
**korrekte** Absicherung: die Funktion darf nur vom Cron-Job (über den
|
||||
Service-Role-Client) aufgerufen werden, nicht von einer eingeloggten
|
||||
HR-Person, sonst könnte diese noch nicht fällige Änderungen vorzeitig
|
||||
erzwingen.
|
||||
**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.
|
||||
|
||||
Keine problematische Fundstelle.
|
||||
**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.
|
||||
|
||||
## `localStorage`, `sessionStorage`, `document.cookie`
|
||||
### Warum es keinen `lib/audit/audit-log.ts` gibt
|
||||
|
||||
Keine Treffer im gesamten App-Code. Sessions laufen ausschließlich über den
|
||||
von `@supabase/ssr` verwalteten Cookie-Adapter (`lib/supabase/server.ts`,
|
||||
`proxy.ts`), nicht über direkten Browser-Storage-Zugriff. Kein Risiko einer
|
||||
Token-Exponierung über clientseitigen Storage.
|
||||
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](datenkatalog.md).
|
||||
|
||||
## `dangerouslySetInnerHTML`, `innerHTML`
|
||||
### Der nächtliche Lauf
|
||||
|
||||
Keine Treffer. Keine rohe HTML-Injection-Fläche im Code.
|
||||
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`.
|
||||
|
||||
## `Authorization`
|
||||
## Offen
|
||||
|
||||
Einzige Fundstelle: `tests/unit/security.test.ts` (prüft den Cron-Route-
|
||||
Guard). Die Route selbst liest den Header über
|
||||
`request.headers.get("authorization")` (Kleinschreibung) — HTTP-Header sind
|
||||
case-insensitiv und `Headers.get()` behandelt sie entsprechend, das ist
|
||||
korrekt und wird bereits durch drei Tests abgedeckt (fehlender Header,
|
||||
falscher Wert, nicht konfiguriertes `CRON_SECRET`).
|
||||
|
||||
## `audit_logs` / `planned_changes` (aus der Aufgabenstellung)
|
||||
|
||||
Keine Treffer unter diesen exakten Namen — das reale Schema heißt
|
||||
`audit_log` (Singular) bzw. `pending_org_changes`. Siehe
|
||||
[`docs/data-model.md`](data-model.md) für die vollständige Zuordnung.
|
||||
|
||||
**Audit-Log-Abdeckung geprüft:** Jede mutierende Server Action (`actions/
|
||||
employees.ts`, `actions/positions.ts`, `actions/reorg.ts`) ruft ausschließlich
|
||||
`supabase.rpc(...)` auf — keine einzige schreibt direkt per
|
||||
`.from(...).insert()/.update()/.delete()` auf `employees`, `positions`,
|
||||
`employee_notes`, `employee_dependents` oder `profiles`. Jede der
|
||||
dahinterliegenden SQL-Funktionen (`hire_employee`, `transfer_employee`,
|
||||
`promote_employee`, `start_karenz`, `record_karenz_return`,
|
||||
`change_employee_data`, `apply_reorg`, `undo_reorg`,
|
||||
`staff_position_internally`, `delete_position`, `add_employee_dependent`,
|
||||
`delete_employee_dependent`, `add_employee_note`,
|
||||
`complete_employee_note`) schreibt ihren `audit_log`-Eintrag in derselben
|
||||
Transaktion wie die eigentliche Datenänderung. Für die aktuell existierenden
|
||||
Mutationspfade ist damit lückenlos sichergestellt, dass keine Änderung ohne
|
||||
Audit-Eintrag möglich ist — ein fehlgeschlagener Audit-Insert lässt die
|
||||
gesamte Transaktion fehlschlagen.
|
||||
|
||||
Ausnahme (bewusst, kein Gap): `apply_due_pending_changes()` (vom Cron-Job
|
||||
aufgerufen) schreibt beim tatsächlichen Anwenden einer fälligen Änderung
|
||||
keinen zusätzlichen `audit_log`-Eintrag — der Audit-Eintrag für diese
|
||||
Änderung wurde bereits zum Zeitpunkt der Anforderung geschrieben (von
|
||||
`transfer_employee`/`start_karenz`/etc.), datiert auf das Wirksamkeitsdatum.
|
||||
Ein zweiter Eintrag beim tatsächlichen Anwenden würde denselben
|
||||
Geschäftsvorfall doppelt loggen.
|
||||
|
||||
## Warum hier kein neues `lib/audit/audit-log.ts` entstanden ist
|
||||
|
||||
Eine App-seitige `writeAuditLog()`-Hilfsfunktion wäre eine zweite,
|
||||
nicht-transaktionale Logging-Quelle neben der bestehenden — sie könnte
|
||||
fehlschlagen, nachdem die eigentliche Mutation bereits committet wurde, und
|
||||
so eine Änderung ohne Audit-Spur hinterlassen. Die bestehende Lösung
|
||||
(Audit-Insert in derselben SQL-Funktion/Transaktion) ist strenger. Ein
|
||||
App-seitiger Helfer wäre daher eine Verschlechterung, kein Fix.
|
||||
|
||||
## Offene Punkte (siehe README → "Known TODOs")
|
||||
|
||||
- Kein Content-Security-Policy-Header (bewusst zurückgestellt, siehe
|
||||
Kommentar in `next.config.ts` — Skript-/Style-/Connect-Quellen noch nicht
|
||||
vollständig inventarisiert).
|
||||
- `.scratch_shots/` enthielt PNG-Screenshots einer Testsitzung mit
|
||||
Beispiel-Notiztext ("vertrauliches Gespräch zur Verifikation" — Testdaten,
|
||||
keine echten Personendaten identifiziert). Ordner ist jetzt über
|
||||
`.gitignore` ausgeschlossen; Inhalt selbst wurde nicht gelöscht (siehe
|
||||
Hinweis unten).
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user