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,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?
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
|
||||
|
||||
@@ -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