# Runbook — internes Zugriffsmodell (#117): Anwenden, Prüfen, Zurücknehmen

> **Kontext**: Produktentscheidung Kais, 04.08.2026. SupplierPulse ist rein intern.
> Jeder angemeldete Nutzer darf die **Fachdaten** lesen und bearbeiten; **anonym**
> kommt nichts durch; **Rollen, Rechte, Benutzerverwaltung, Einladungen,
> administrative Konfiguration und Protokolle** bleiben beim Schreiben geschützt.
>
> Migration: `supabase/migrations/supabase-migration-internal-open-access-model.sql`
> Rollback: `…-internal-open-access-model-rollback.sql`
> Katalog: `MIGRATIONS.md` §7s (#117) und Rollback-Zeile R40.
>
> **Anwenden ist Operator-Arbeit.** Der Produktionszugang liegt bei Kais
> (`CLAUDE.md`, Stop-Regel „prod state is operator-owned"). Dieses Runbook ist so
> geschrieben, dass es ohne Rückfragen abgearbeitet werden kann.

---

## 0. Was diese Migration bewirkt — in einem Satz

Jede Fachtabelle trägt danach genau **eine** Policy
(`FOR ALL TO authenticated USING (true) WITH CHECK (true)`), alle historischen
Regeln fallen weg; die Tabellen der Sicherheitsebene behalten ihre Schreibregeln
und bekommen höchstens eine offene Leseregel dazu.

**Ausdrückliche Bedingung:** Damit sieht und ändert jeder Login jede
Lieferantenkalkulation. Das ist gewollt, solange der Nutzerkreis intern ist. Vor
einer Öffnung nach außen (Adesso-Übergabe, BMW-Pilot mit externen Nutzern) ist das
Modell neu zu bewerten.

---

## 1. Vorbedingungen

| Punkt | Prüfung |
| --- | --- |
| Backup | Supabase → Database → Backups: ein Point-in-Time-Stand von **heute** existiert. Ohne ihn nicht anwenden. |
| Fenster | Die Migration läuft in einer Transaktion über alle Tabellen und nimmt kurz `ACCESS EXCLUSIVE`-Sperren. Außerhalb der Stoßzeit anwenden. |
| CI | Der `rls`-Job auf dem zugehörigen PR ist grün — er wendet dieselbe Datei auf ein frisches Postgres 17 an, prüft das Ergebnis und fährt anschließend den Rollback. |
| Kein Teilstand | Die Datei ist eine Transaktion. Bricht sie ab, ist nichts geändert. Nicht in Teilen kopieren. |

Diagnose vorab (Ist-Zustand festhalten, damit „vorher/nachher" belegbar ist):

```sql
-- Wie viele Policies stehen heute je Tabelle?
SELECT tablename, count(*) AS policies
  FROM pg_policies WHERE schemaname = 'public'
 GROUP BY tablename ORDER BY tablename;

-- Gibt es RESTRICTIVE Policies? (projects_released_scope wird erwartet)
SELECT tablename, policyname FROM pg_policies
 WHERE schemaname = 'public' AND permissive = 'RESTRICTIVE';
```

Beide Ergebnisse vor dem Anwenden sichern (Screenshot oder CSV-Export genügt).
Der Rollback braucht sie nicht — er arbeitet aus der Snapshot-Tabelle —, aber sie
sind die unabhängige Gegenprobe dazu.

---

## 2. Anwenden

1. Supabase Dashboard → **SQL Editor** → New query.
2. Inhalt von `supabase/migrations/supabase-migration-internal-open-access-model.sql`
   **vollständig** einfügen (inklusive `BEGIN;` und `COMMIT;`).
3. Ausführen. Erwartete Rückmeldung: `Success. No rows returned.`
4. Bei Fehlermeldung: nichts weiter tun, Meldung sichern, Abschnitt 5 lesen.
   Die Transaktion hat dann nichts geändert.

Die Datei ist wiederholbar. Ein zweiter Lauf ändert am Ergebnis nichts und
sichert insbesondere **nicht** erneut den Policy-Bestand — sonst würde er den
bereits offenen Zustand sichern und den Rückweg entwerten.

---

## 3. Verifikation (nach dem Anwenden, in dieser Reihenfolge)

```sql
-- 3.1 Landkarte: welche Tabelle gehört zu welcher Ebene?
SELECT access_class, count(*) AS tabellen
  FROM public.rls_access_class GROUP BY access_class ORDER BY access_class;
```
Erwartet: drei Klassen, `open_business_data` ist die mit Abstand größte.

```sql
-- 3.2 Fachdaten: genau eine Policy, und zwar die offene
SELECT c.tablename, count(p.policyname) AS anzahl, min(p.policyname) AS name
  FROM public.rls_access_class c
  LEFT JOIN pg_policies p ON p.schemaname = 'public' AND p.tablename = c.tablename
 WHERE c.access_class = 'open_business_data'
 GROUP BY c.tablename
HAVING count(p.policyname) <> 1 OR min(p.policyname) <> c.tablename || '_internal_rw';
```
Erwartet: **0 Zeilen**. Jede Zeile hier ist ein Befund, kein Rundungsfehler.

```sql
-- 3.3 Sicherheitsebene: nirgends eine offene Schreibregel
SELECT p.tablename, p.policyname
  FROM pg_policies p JOIN public.rls_access_class c ON c.tablename = p.tablename
 WHERE c.access_class LIKE 'protected%'
   AND p.policyname LIKE '%\_internal\_rw' ESCAPE '\';
```
Erwartet: **0 Zeilen**.

```sql
-- 3.4 Protokolle bleiben append-only
SELECT tablename, policyname, cmd FROM pg_policies
 WHERE schemaname = 'public'
   AND tablename IN ('audit_log', 'user_audit_log', 'master_data_audit_log')
   AND cmd IN ('UPDATE', 'DELETE', 'ALL');
```
Erwartet: **0 Zeilen**.

```sql
-- 3.5 Keine Tabelle ohne RLS
SELECT relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
 WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT c.relrowsecurity;
```
Erwartet: **0 Zeilen**.

```sql
-- 3.6 Der Rückweg existiert
SELECT count(*) AS gesicherte_policies, min(captured_at) AS gesichert_am
  FROM public.rls_policy_snapshot;
```
Erwartet: eine dreistellige Zahl und der Zeitstempel von eben. **Ist die Zahl 0,
gibt es keinen Rückweg — dann sofort Abschnitt 5.**

### 3.7 Gegenprobe in der Anwendung (nicht überspringen)

SQL belegt die Regeln, nicht die Wirkung. Zwei Handgriffe in der laufenden App:

1. Mit einem **normalen** Konto (kein admin) ein Projekt öffnen, das diesem Konto
   nicht gehört, und dort eine Kleinigkeit speichern. Erwartet: geht.
2. Im **Log-out-Zustand** (privates Fenster) eine Detailseite direkt aufrufen.
   Erwartet: kein Datenzugriff, Weiterleitung auf Login.

---

## 4. Rollback

Nur die Datei `…-internal-open-access-model-rollback.sql` verwenden — sie stellt
den gesicherten Bestand wortgleich wieder her.

1. SQL Editor → Inhalt vollständig einfügen → ausführen.
2. Erwartete Rückmeldung: `Success. No rows returned.`
3. Prüfen:

```sql
-- 4.1 Keine Reste der Umstellung
SELECT count(*) FROM pg_policies
 WHERE schemaname = 'public'
   AND (policyname LIKE '%\_internal\_rw' ESCAPE '\'
     OR policyname LIKE '%\_internal\_read' ESCAPE '\');   -- erwartet: 0

-- 4.2 Stichprobe: die Regeln aus #115 sind zurück
SELECT policyname, cmd FROM pg_policies
 WHERE schemaname = 'public' AND tablename = 'qaf_comparison'
 ORDER BY policyname;                                       -- erwartet: _read (SELECT) + _write (ALL)

-- 4.3 Snapshot ist geleert
SELECT count(*) FROM public.rls_policy_snapshot;            -- erwartet: 0
```

**Bricht der Rollback mit „rls_policy_snapshot ist leer" ab**, ist er bereits
gelaufen oder wurde nie gesichert. Dann nicht erneut versuchen, sondern die
historischen Migrationen aus `MIGRATIONS.md` erneut anwenden (#101, #109, #115,
#95, #99) — in dieser Reihenfolge.

---

## 5. Wenn etwas schiefgeht

| Symptom | Bedeutung | Schritt |
| --- | --- | --- |
| Fehler beim Anwenden | Transaktion zurückgerollt, nichts geändert | Meldung sichern, Abschnitt 1 erneut prüfen (Rechte, Postgres-Version), nichts weiter tun |
| 3.2 liefert Zeilen | Eine Fachtabelle hat mehr oder andere Policies als vorgesehen | Nicht selbst nachbessern — Ausgabe sichern, Rollback (Abschnitt 4) |
| 3.6 liefert 0 | Kein Rückweg gesichert | **Nicht** weiterarbeiten. Point-in-Time-Restore ist dann die einzige Umkehr |
| App zeigt „row-level security policy" | Eine Schreiboperation trifft die Sicherheitsebene | Prüfen, welche Tabelle: gehört sie fachlich zu den offenen Daten, ist die Klassifikation zu korrigieren — als Migration, nicht per Hand im Editor |
| Nutzer sieht zu viel | Genau das ist das neue Soll | Keine Störung. Wenn unerwünscht: Produktentscheidung revidieren, dann Rollback |

---

## 6. Nach dem Anwenden

- `MIGRATIONS.md` §7s: Status von „not yet applied" auf **APPLIED to production
  \<Datum, Uhrzeit\>** setzen, mit den Ergebnissen aus Abschnitt 3 als Beleg.
- Ergebnis von 3.1 (die Klassenverteilung) in den Eintrag übernehmen — das ist die
  kompakteste Beschreibung des Ist-Zustands.
- `docs/foundation/schema-drift/`: Die beiden Tabellen `rls_policy_snapshot` und
  `rls_access_class` wechseln damit von `expected_migration_delta` zu „in
  Produktion vorhanden". Baseline nachziehen:
  `node scripts/schema-drift/compare.mjs --update-baseline`.
