Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

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:
2026-09-07 10:43:22 +02:00
parent 5c310c3a58
commit b87c8ad64c
155 changed files with 386 additions and 4014 deletions

View File

@@ -1,148 +0,0 @@
# Umstieg auf Azure — Sicherheitsarchitektur
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
Supabase als Abhängigkeit entfernt.
## Ausgangslage, gemessen
| | Anzahl |
|---|---|
| RLS-Policies | 21 |
| `auth.uid()` / `auth.users` in Migrationen | 79 |
| Fremdschlüssel auf `auth.users` | 9 |
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
| Dateien mit Supabase-Import | 10 |
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
Proxy und nicht der Anwendungscode.
## Der entscheidende Befund
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
Stelle:
```sql
create or replace function is_hr_user() returns boolean
language sql security definer stable as $$
select exists (
select 1 from profiles p
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
);
$$;
```
Alle 21 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
war meine Sorge bei Variante B, und sie ist ausgeräumt.
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
## Zielarchitektur
### 1. Benutzertabelle statt `auth.users`
```sql
create table app_users (
id uuid primary key default gen_random_uuid(),
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
email text not null,
created_at timestamptz not null default now()
);
```
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
funktioniert unverändert.
### 2. Sitzungskontext statt `auth.uid()`
```sql
create or replace function current_app_user() returns uuid
language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid;
$$;
```
`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst
Wort für Wort gleich.
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
**Hier entscheidet sich, ob die Migration sicher ist.**
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
`set local app.user_id` ausführt:
```ts
await db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
return tx.select()…;
});
```
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
Das muss strukturell unmöglich sein, nicht per Konvention.
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
zurück — statt alles.
### 4. Anmeldung
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
(`is_active` ist per Vorgabe `false`).
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
### 5. Datenzugriff
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
## Was bewusst gleich bleibt
- **Alle 21 RLS-Policies**, unverändert
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
- Die Geschäftslogik in den RPCs
## Reihenfolge
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
3. Entra-ID-Anmeldung
4. Die 50 Datenzugriffe umstellen
5. Supabase-Pakete entfernen
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
prüft, dass die zweite die erste nicht sieht.
## Offene Fragen an die Kunden-IT
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
- Wer betreibt und patcht?

View File

@@ -1,113 +0,0 @@
# Datenmodell
> **Veraltet — siehe `docs/datenkatalog.md`.**
>
> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in
> wesentlichen Teilen nicht mehr:
>
> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle
> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell
> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit
> zeitabhängigen Zuordnungen.
> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet
> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und
> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`.
> - Die Zahl der RLS-Policies ist 21, nicht 58.
>
> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was
> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den
> effective-dated changes — steht dort ebenfalls.
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei
strukturellen Änderungen mitgepflegt.
## Grundprinzipien
- **Person ist nicht Position.** `employees` (Personen) und `positions`
(Planstellen/Ausschreibungen) sind getrennte Tabellen. Eine Position wird
bei Einstellung mit einer Person verknüpft (`filled_by_employee_id`),
existiert aber unabhängig davon (offene Ausschreibung).
- **History ist append-only.** `employee_history` (Ereignisse pro Person)
und `audit_log` (systemweit, wer hat was wann geändert) haben keine
Update-/Delete-Policy — RLS erlaubt nur `select`/`insert`. Korrekturen
erfolgen durch einen neuen, kompensierenden Eintrag, nie durch Ändern der
Historie (siehe `undo_reorg`, das eine "Reorganisation rückgängig"-Zeile
anhängt statt die ursprünglichen Zeilen zu löschen).
- **Audit-Log ist Pflicht bei Änderungen — und lebt in der Datenbank, nicht
im App-Code.** Jede mutierende SQL-Funktion (`hire_employee`,
`change_employee_data`, `add_employee_dependent`, `add_employee_note`, …)
schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die
eigentliche Änderung. Das ist bewusst atomar: ein fehlgeschlagener
Audit-Insert lässt die ganze Transaktion fehlschlagen, statt still eine
Änderung ohne Log zu hinterlassen. Es gibt keinen App-seitigen
`writeAuditLog()`-Helper und es sollte auch keinen geben — das würde eine
zweite, nicht-atomare Logging-Quelle neben der bestehenden schaffen.
- **Service-Role-Zugriff ist server-only.** `lib/supabase/admin.ts` ist die
einzige Stelle, die den Service-Role-Key verwendet; `import "server-only"`
macht einen versehentlichen Client-Import zu einem Build-Fehler. Alles
andere läuft über den anon key + RLS.
- **RLS ist bereits aktiv**, nicht nur für die Produktion vorgemerkt: jede
Tabelle hat `enable row level security` plus mindestens eine Policy
(siehe unten). Zusätzlich existieren explizite `grant`-Statements für
`anon`/`authenticated`/`service_role`
(`20260714120500_default_grants.sql`) — ohne die schlägt jede Query auch
mit korrekter RLS-Policy mit "permission denied" fehl, weil Postgres
Objekt-Rechte unabhängig von RLS prüft.
## Zugriffsmodell
Ein einziges Rollenmodell, kein Mehrfach-Rollen-System:
- `profiles.role` ist per Check-Constraint auf den einzigen Wert `'hr'`
fixiert (Migration `20260714120000_hr_only_access.sql`).
- `profiles.is_active` (default `false`) muss zusätzlich wahr sein.
- Die SQL-Funktion `is_hr_user()` (SECURITY DEFINER, vermeidet RLS-Rekursion
auf `profiles`) prüft beides und gated praktisch jede Policy im Schema.
- `proxy.ts` (Next.js Proxy, ehem. Middleware) spiegelt dieselbe Prüfung auf
App-Ebene: nicht eingeloggt → `/login`; eingeloggt aber nicht aktive
HR-Person → `/login` mit Fehlermeldung. Das ist bewusst nur "defense in
depth" — die eigentliche Schranke ist RLS, nicht die UI-Prüfung.
- Es gibt **keine** granulareren Rollen (kein `hr_admin`/`read_only`/
`it_admin`-Split o. Ä.) und aktuell keinen zweiten Anwendungsfall dafür.
Sollte das nötig werden, ist der richtige Ansatz eine neue Migration, die
`is_hr_user()` um echte Rollenspalten erweitert — nicht ein
App-seitiges Rollenmodell, das der DB-Policy-Ebene nicht entspricht.
## Kernentitäten
| Tabelle | Zweck |
|---|---|
| `divisions` / `departments` / `teams` | Org-Hierarchie ("Bereich" 20xx / "Abteilung" 21xx / "Team" 22xx), je mit eindeutiger `org_number`. |
| `locations` | Standorte, an ein Land gebunden (steuert die Standort-Picklist im UI). |
| `profiles` | Ein Datensatz pro Supabase-Auth-User; Rolle + Aktivierungsstatus (siehe oben). |
| `employees` | Zentrale Personentabelle: Stammdaten, Vertrag (Vollzeit/Teilzeit, befristet/unbefristet), Org-Zuordnung, Status (`Aktiv`/`Karenz`/`Geplant`/`Ausgetreten`), Rolle & Anstellung (Angestellte:r/Arbeiter:in, Kollektivvertrag, Arbeitstage, Betriebsrat/Dienstwagen/laterale Führung/C-Level-Flags), akademische Titel (Prefix/Suffix-Arrays). |
| `employee_history` | Append-only Ereignis-Timeline pro Person (fester Enum: Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr). |
| `employee_dependents` | Angehörige (Ehepartner:in/Lebenspartner:in/Kind/Sonstige) je Mitarbeiter:in; Edit = Löschen + Neuanlage, kein In-place-Update. |
| `employee_notes` | HR-Notizen je Mitarbeiter:in, add-only, mit optionalem "Wiedervorlage am"-Datum. Bewusst nicht autor-gescoped — jede aktive HR-Person sieht jede offene Notiz ("Meine Notizen" ist ein geteiltes Postfach, kein persönliches). |
| `positions` | Planstellen mit eindeutiger `position_number` (^6\d{7}$), Status `open`/`filled`, `valid_from`-Gültigkeitsfenster. |
| `hire_drafts` | Fortsetzbarer Zwischenstand des Neueinstellungs-Wizards (JSONB-Payload), eigentümer-gescoped. |
| `saved_reports` | Gespeicherte Report-Konfigurationen, eigentümer-gescoped. |
| `pending_org_changes` | Effective-dated (zukünftig wirksame) Änderungen — Versetzung/Beförderung/Karenz-Start/-Rückkehr/Vertragsänderung/Reorg — die erst am `effective_date` angewendet werden. Wird von `apply_due_pending_changes()` verarbeitet, aufgerufen vom Cron-Route-Handler. Entspricht dem, was in generischen HR-Schemata oft "planned_changes" heißt. |
| `audit_log` | Systemweiter, unveränderlicher Audit-Trail (wer/wann/was/an wem). Wird ausschließlich von SQL-Funktionen beschrieben, nie direkt aus der App. |
| `reorg_scenarios` / `reorg_moves` | Persistierte Reorg-Pläne inkl. `undo_snapshot` (Pre-Change-Zustand für die Rückgängig-Funktion). |
## Cron / effective-dated changes
`apply_due_pending_changes()` (SQL, `SECURITY DEFINER`) ist die einzige
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 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
`status = 'pending'`, ein zweiter Lauf wirkt daher auf bereits angewendete
Einträge nicht erneut.
## Bekannte Lücken vor Produktivbetrieb
Siehe README.md → "Known TODOs" für den aktuellen Stand.

View File

@@ -3,18 +3,13 @@
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
Datenbank am **13.08.2026**.
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
Die Wahrheit steht in `db/migrations/` (68 Dateien). Dieses Dokument
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
kommt.
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
| | |
|---|---|
| Tabellen | 15 |
@@ -255,7 +250,7 @@ Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
### `app_users`
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
Ersetzt `auth.users` aus der abgelösten Plattform. `external_id` ist die `oid` aus
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.

View File

@@ -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.