Data & Systems
Backend, das hält
Postgres, Supabase und RLS: wo die Daten leben und wer sie anfassen darf. Sicherheit als Autorität des Servers, nicht des Clients. Das Rückgrat von XHUB, Espejo und dieser Schule.
DS-01 Modellierung in Postgres
Lektion Ein Schema in Postgres mit Tabellen, Schlüsseln, Typen und Beziehungen modellieren, das die echten Invarianten der Domäne abbildet und dich nicht drei Wochen später zu schmerzhaften Migrationen zwingt.
Modellierung in Postgres
LektionEin Schema in Postgres mit Tabellen, Schlüsseln, Typen und Beziehungen modellieren, das die echten Invarianten der Domäne abbildet und dich nicht drei Wochen später zu schmerzhaften Migrationen zwingt.
Das Datenmodell ist die teuerste Entscheidung des gesamten Stacks, wenn du sie rückgängig machen willst: Code schreibst du an einem Nachmittag neu, aber eine falsch typisierte Tabelle mit 50.000 Zeilen in Produktion verfolgt dich monatelang. In der geteilten DB von MOONKEY LAB (profiles, progress, proofs) ist ein schwacher Typ oder eine vergessene FK ein Integritätsloch, das keine RLS mehr flickt.
DIE LEKTION
Postgres ist keine «Tabellenkalkulation mit SQL». Es ist eine relationale Engine mit einem ernstzunehmenden Typsystem, und deine erste Aufgabe als Datenoperator ist es, dieses Typsystem so einzusetzen, dass unmögliche Zustände buchstäblich nicht darstellbar sind. Bevor du auch nur ein einziges CREATE TABLE schreibst, zähle die Entitäten der Domäne und die Beziehungen zwischen ihnen auf. In MOONKEY LAB sind die echten Entitäten: ein Nutzer (profiles), sein Fortschritt pro Modul (progress), die Nachweise, die er hochlädt (proofs), das Feedback, das er hinterlässt (feedback) und die Akquise-Leads. Jede davon ist eine Tabelle. Jede 1-zu-N-Beziehung zwischen ihnen ist eine Foreign Key. Fang nicht bei den Spalten an, fang bei den Pfeilen zwischen den Kästchen an.
Der Bezeichner. In Supabase hängt die Tabelle profiles an auth.users, also ist ihr Primärschlüssel KEINE neu generierte id: er ist `id uuid primary key references auth.users(id) on delete cascade`. Das ist das kanonische Supabase-Muster und es ist Absicht — die uuid des authentifizierten Nutzers IST die Identität in deiner gesamten App, und `auth.uid()` (das du in jeder RLS-Policy verwenden wirst) gibt genau diese uuid zurück. Für Kindtabellen wie progress oder proofs verwendest du sehr wohl einen eigenen Schlüssel: `id uuid primary key default gen_random_uuid()`, plus eine Spalte `user_id uuid not null references auth.users(id) on delete cascade`. Achte auf `not null` und auf `on delete cascade`: löschst du einen Nutzer, gehen seine Nachweise mit ihm; liessest du user_id null zu, hättest du verwaiste Zeilen, die keine Policy `user_id = auth.uid()` weder anzeigen noch schützen könnte.
Die Typen sind wichtig und am Anfang günstig richtig zu treffen. Für Zeitstempel verwende IMMER `timestamptz` (with time zone), niemals nur `timestamp` — ein Projekt mit Nutzern in Chiang Mai, Mallorca und Madrid kann sich keine Zeitzonen-Mehrdeutigkeit leisten, und `timestamptz` speichert in UTC und konvertiert an der Grenze. Für Geld `numeric`, niemals `float` (binäre Gleitkommazahlen stellen 0.10 nicht exakt dar). Für ein Feld mit einer abgeschlossenen Wertemenge — der Rang des Operators: monkey, gorilla, eagle — hast du zwei ehrliche Optionen: ein Postgres-`enum` oder `text` mit einem `check (role in ('monkey','gorilla','eagle'))`. Ich bevorzuge in jungen Projekten den CHECK gegenüber dem enum: einen Wert zu einem enum hinzuzufügen erfordert `ALTER TYPE` und hat transaktionale Reibung, während das Ändern eines CHECK ein gewöhnliches Tabellen-ALTER ist. Für halbstrukturierte Daten — das Payload eines proof, flexible Metadaten — verwende `jsonb`, nicht `json`: jsonb ist binär, mit GIN indexierbar und dedupliziert Schlüssel.
Defaults und NOT NULL sind deine erste Validierungslinie, noch vor jeder RLS oder jedem App-Check. `created_at timestamptz not null default now()` bedeutet, dass es unmöglich ist, eine Zeile ohne Datum einzufügen. `status text not null default 'forming'` bedeutet, dass der Status nie null ist und dein Code daher nie `if status is null`-Zweige braucht. Jede Spalte, die du nullable lässt, ist ein bedingter Zweig, den du für immer in jedem Code bezahlst, der sie liest. Die Regel: eine Spalte ist standardmässig NOT NULL, und du machst sie nur dann nullable, wenn das Fehlen eines Werts ein legitimer Domänenzustand ist, der sich von einem leeren Wert unterscheidet.
Eindeutigkeit und Indizes. Die Beziehung «ein Nutzer hat genau einen Fortschrittseintrag pro Modul» ist kein Kommentar: sie ist `unique (user_id, module_code)`. Diese Unique-Constraint gibt dir ausserdem den Index, den du für den Upsert brauchst (`insert ... on conflict (user_id, module_code) do update`), genau so, wie MOONKEY den Fortschritt persistiert. Abgesehen von den Schlüsseln, lege Indizes auf den Spalten an, nach denen du wirklich filterst: fragst du proofs ständig nach `user_id` ab, dann `create index on proofs (user_id)`. Aber indexiere nicht reflexartig — jeder Index verlangsamt die INSERTs und belegt Platz; indexiere, was du messbar abfragst, nicht das, was du dir einbildest abzufragen.
Wende das Schema als versionierte Migration an, niemals von Hand im SQL-Editor. In Supabase ist das `apply_migration` (eine .sql-Datei mit Name und Timestamp), nicht ein loses `execute_sql`. Der Unterschied ist Nachvollziehbarkeit: in sechs Monaten willst du wissen, warum proofs diese Spalte hat, und die Antwort steht in der Migrationshistorie, nicht in deinem Gedächtnis. Nach dem Anwenden führst du `generate_typescript_types` aus, damit der TypeScript-Typ des Clients aus dem echten Schema abgeleitet wird — so ist die DB die einzige Quelle der Wahrheit und das Frontend kann nicht über die Form der Daten lügen.
Ein konkretes und vollständiges Beispiel für proofs, die Tabelle, in der ein Operator den Nachweis hochlädt, dass er ein Deliverable abgeschlossen hat: ein eigener uuid-Schlüssel, eine user_id NOT NULL mit FK und cascade, ein module_code mit CHECK gegen die Liste gültiger Codes, ein jsonb-Payload, ein created_at timestamptz NOT NULL default now() und ein Index auf user_id. Dieses Design sorgt dafür, dass (a) jede Zeile zu einem existierenden Nutzer gehört, (b) keine Zeile verwaist, (c) kein erfundener module_code reinkommt und (d) die Abfrage «meine Nachweise» schnell ist. Auf dieser Basis — und nur auf ihr — ergibt es Sinn, im nächsten Modul RLS aufzusetzen.
ÜBUNG
Schreibe im Supabase-Projekt moonkey-lab (oder einer Entwicklungs-Branch) die SQL-Migration, die die Tabelle `proofs` von Grund auf erstellt: id uuid PK mit gen_random_uuid(), user_id uuid NOT NULL references auth.users(id) on delete cascade, module_code text NOT NULL mit CHECK gegen mindestens drei echte Codes ('DS-01','DS-02','DS-03'), payload jsonb NOT NULL default '{}', created_at timestamptz NOT NULL default now() und einen Index auf user_id. Wende sie mit apply_migration an. Versuche dann absichtlich ein INSERT mit module_code='XX-99' und ein weiteres mit einer user_id, deren uuid nicht in auth.users existiert, und prüfe, dass Postgres beide ablehnt. Führe schliesslich generate_typescript_types aus und verifiziere, dass der generierte Typ Proof exakt deine Spalten und Nullability widerspiegelt.
ERGEBNIS
Eine Migrationsdatei `supabase/migrations/<timestamp>_create_proofs.sql`, angewendet auf einer Branch, plus ein Screenshot des Postgres-Fehlers beim ungültigen INSERT (Verletzung von CHECK und FK) und der aus dem Schema generierte TypeScript-Typ `Proof`.
KERNEINSICHT
Das richtige Datenmodell ist das, welches einen Anwendungsbug in einen Datenbankfehler verwandelt. Jeder CHECK, jedes NOT NULL und jede FK ist eine ganze Klasse von Bugs, die dein Anwendungscode nicht mehr abwehren muss — weil Postgres sie ablehnt, bevor sie überhaupt existieren.
ZU VERMEIDENDE FEHLER
- ×`timestamp` statt `timestamptz` verwenden und den Zeitversatz erst entdecken, wenn ein Nutzer in Thailand falsche Daten sieht; in Produktion ist es dann schon zu spät.
- ×Spalten «sicherheitshalber» nullable lassen: jedes unnötige nullable ist ein `if x is null`-Zweig, den du durch den gesamten Code schleppst, der die Tabelle liest.
- ×Die Tabelle profiles mit einer eigenen id statt `references auth.users(id)` erstellen und so die Kette zwischen auth.uid() und der Identität brechen — wodurch die spätere RLS kein Feld hat, gegen das sie vergleichen kann.
- ×Das Schema mit execute_sql von Hand anwenden statt mit apply_migration: du verlierst die versionierte Historie und niemand kann mehr rekonstruieren, warum das Schema so ist, wie es ist.
- ×Reflexartig Indizes auf Spalten säen, nach denen du nie filterst: du bestrafst jedes INSERT und belegst Platz, ohne irgendeine echte Abfrage zu beschleunigen.
DS-02 RLS: die Autorität ist der Server
Lektion Row-Level-Security-Policies schreiben und verifizieren, die dafür sorgen, dass ein Nutzer nur seine eigenen Zeilen lesen und schreiben kann — im Verständnis, dass das Gate im Client UX ist und die RLS die einzige echte Sicherheit auf einer statischen Site.
RLS: die Autorität ist der Server
LektionRow-Level-Security-Policies schreiben und verifizieren, die dafür sorgen, dass ein Nutzer nur seine eigenen Zeilen lesen und schreiben kann — im Verständnis, dass das Gate im Client UX ist und die RLS die einzige echte Sicherheit auf einer statischen Site.
MOONKEY LAB und Espejo sind statische Sites ohne serverseitigen Handler: es gibt kein Backend, in das du ein `if (user.id === row.user_id)` packen könntest. Der Client trägt die anon key, die per Design öffentlich ist. Ist die RLS falsch, liest jeder mit offenen DevTools die gesamte profiles-Tabelle. Die RLS ist nicht eine weitere Verteidigungsschicht: sie ist die EINZIGE Schicht.
DIE LEKTION
Verinnerliche das Bedrohungsmodell, bevor du eine Policy anfasst. Bei einer SSG mit Supabase nutzt der Angreifer NICHT deine Oberfläche. Er öffnet die Browser-Konsole, nimmt die anon key (die im Bundle steckt, weil sie dort stecken muss), instanziiert seinen eigenen Supabase-Client und ruft `supabase.from('profiles').select('*')` auf. Deine Datei admin.astro, die das Panel «versteckt», existiert für ihn nicht. Der deaktivierte Button existiert für ihn nicht. Das Einzige, was sich zwischen sein SELECT und alle Zeilen aller Nutzer stellt, ist die Row-Level-Security von Postgres. Daher der Satz des Moduls: das Gate im Client ist UX (es verbessert die Erfahrung des legitimen Nutzers), die RLS ist die Sicherheit (sie stoppt den illegitimen).
RLS wird pro Tabelle aktiviert und ist standardmässig deny-all. `alter table profiles enable row level security` — und in diesem Moment kann ohne jede Policy NIEMAND (ausser der Service-Rolle und dem Tabelleneigentümer) irgendetwas lesen oder schreiben. Das ist genau richtig: du startest bei null Berechtigungen und öffnest exakt das, was du brauchst. Der katastrophale Fehler ist, RLS zu aktivieren und eine Operation zu vergessen: setzt du SELECT-Policies, aber keine für INSERT, schlagen die INSERTs stillschweigend fehl; schlimmer noch, aktivierst du KEINE RLS auf einer neuen Tabelle, ist sie für die anon key völlig offen. Deshalb führt DS-06 die Advisors ein: sie erkennen genau die Tabelle ohne RLS, die du vergessen hast.
Eine Policy hat zwei Klauseln, die alle verwechseln: USING und WITH CHECK. USING filtert, welche existierenden Zeilen die Operation sieht oder anfasst (SELECT, UPDATE, DELETE). WITH CHECK validiert, welche neuen oder geänderten Zeilen geschrieben werden dürfen (INSERT, UPDATE). Für SELECT gibt es nur USING. Für INSERT gibt es nur WITH CHECK. Für UPDATE gibt es BEIDE: USING sagt, welche Zeilen du aktualisieren darfst, WITH CHECK sagt, in was sie sich verwandeln dürfen. Ein klassischer Fehler ist, USING bei einem INSERT zu setzen, wo es nichts tut, oder nur USING bei einem UPDATE zu setzen und dem Nutzer zu erlauben, seine Zeile auf eine andere user_id zu verschieben. Das self-or-admin-Muster von MOONKEY: `using (user_id = auth.uid() or is_admin())`.
Das kanonische self-Muster. Für proofs ist die echte INSERT-Policy von MOONKEY `proofs_insert_self`: `create policy proofs_insert_self on proofs for insert to authenticated with check (user_id = auth.uid())`. Lies sie laut: ein authentifizierter Nutzer darf einen proof nur dann einfügen, wenn die user_id der eingefügten Zeile gleich seiner eigenen Identität ist. Er kann keinen proof im Namen eines anderen erstellen, denn WITH CHECK würde jede Zeile mit user_id ≠ auth.uid() ablehnen. Achte auf `to authenticated`: die Policy gilt nicht einmal für die anon-Rolle, also kann ein Besucher ohne Session gar nichts einfügen. Für SELECT das self-or-admin-Muster: `using (id = auth.uid() or is_admin())` auf profiles — du siehst deine Zeile, oder alle, wenn du Admin bist.
auth.uid() ist das Herz des Systems und es lohnt sich zu wissen, was es ist: eine SECURITY-DEFINER-Funktion von Supabase, die das `sub` aus dem JWT extrahiert, das der Client bei jeder Anfrage mitschickt. Der Client kann es nicht fälschen, weil das JWT von Supabase mit einem Geheimnis signiert ist, das der Client nicht hat; eine Manipulation macht die Signatur ungültig und Postgres lehnt die Session ab. Deshalb ist `user_id = auth.uid()` auf eine Weise vertrauenswürdig, wie es `user_id = <Wert, den der Client geschickt hat>` niemals wäre. Die Autorität wohnt in der Signatur des Tokens, nicht im guten Willen des Frontends.
Verifikation durch Impersonation — das trennt einen ernsthaften Operator von einem, der nur «glaubt», dass seine RLS funktioniert. Es reicht nicht, die Policy zu lesen und zu nicken. Im SQL-Editor von Supabase kannst du simulieren, ein bestimmter Nutzer zu sein, indem du Rolle und Claim setzt: `set local role authenticated; set local request.jwt.claims to '{"sub":"<uuid-von-Nutzer-A>"}';` und dann `select * from profiles`. Siehst du Zeilen anderer Nutzer, ist deine RLS kaputt. Das Sicherheitsmodell von MOONKEY sagt ausdrücklich «verifiziert durch Impersonation»: ein Nicht-Admin kann keine fremden Zeilen lesen, und das wurde BEWIESEN, nicht angenommen. Mach es genauso: impersoniere Nutzer A, versuche die Zeile von Nutzer B zu lesen und verlange, dass das Ergebnis null Zeilen ist.
Zwei letzte Fallen. Erste: die Rolle `service_role` (der Service-Key, NIEMALS im Client) umgeht jede RLS — deshalb wohnt sie nie in einer SSG und wird nur in vertrauenswürdigen Edge Functions verwendet. Zweite: Policies sind standardmässig permissiv und werden mit OR kombiniert, nicht mit AND. Hast du zwei SELECT-Policies auf derselben Tabelle, ist eine Zeile, die für IRGENDEINE der beiden sichtbar ist, sichtbar. Das überrascht: eine Policy hinzuzufügen schränkt nie ein, es erweitert nur. Um einzuschränken, brauchst du explizite RESTRICTIVE-Policies oder — einfacher und üblich — eine einzige, gut geschriebene Policy pro Operation.
ÜBUNG
Auf einer Branch von moonkey-lab, auf der bereits erstellten Tabelle proofs: aktiviere RLS und schreibe drei Policies — `proofs_select_self` (SELECT, using user_id=auth.uid() or is_admin()), `proofs_insert_self` (INSERT, with check user_id=auth.uid()) und KEINE UPDATE/DELETE-Policy (deny per Default). Erstelle zwei Testnutzer A und B mit je einem proof. Impersoniere im SQL-Editor A (set local role authenticated + jwt claims sub=uuid_A) und verifiziere: (1) ein SELECT gibt nur den proof von A zurück; (2) ein INSERT mit user_id=uuid_B wird von WITH CHECK abgelehnt; (3) ein DELETE des proof von A wird abgelehnt, weil es keine Policy gibt. Dokumentiere jedes der drei Ergebnisse.
ERGEBNIS
Migration mit den drei Policies auf proofs, plus ein Log der Impersonations-Session, das die drei Verhalten belegt: A sieht B nicht (SELECT), A kann nicht als B schreiben (INSERT mit WITH CHECK), und niemand kann löschen (keine DELETE-Policy).
KERNEINSICHT
Eine RLS, die du nicht absichtlich durch Impersonation eines anderen Nutzers gebrochen hast, ist eine RLS, von der du nicht weisst, ob sie funktioniert. Vertrauen in Sicherheit kommt nicht daher, die Policy zu lesen; es kommt daher, den Missbrauch versucht und Postgres ihn ablehnen gesehen zu haben.
ZU VERMEIDENDE FEHLER
- ×Das Gate von admin.astro mit Sicherheit verwechseln: das Panel im Frontend zu verstecken hindert den Angreifer nicht daran, die Tabelle mit der anon key direkt aus der Konsole aufzurufen.
- ×USING setzen, wo WITH CHECK hingehört (oder umgekehrt): ein INSERT mit nur USING validiert nichts und lässt Zeilen mit fremder user_id durch.
- ×RLS aktivieren und die Policy einer Operation vergessen: die INSERTs beginnen stillschweigend zu scheitern, oder schlimmer, eine Tabelle ohne RLS steht der anon key sperrangelweit offen.
- ×Annehmen, dass die RLS funktioniert, weil man sie liest, statt sie durch Impersonation mit set role + jwt claims zu verifizieren und null fremde Zeilen zu verlangen.
- ×Glauben, dass eine zweite permissive Policy den Zugriff einschränkt: Policies werden mit OR kombiniert, also erweitert jede neue Policy nur das Sichtbare, sie reduziert es nie.
DS-03 Auth Magic-Link & Sessions
Lektion Authentifizierung per Magic-Link mit Supabase implementieren und die Session clientseitig ehrlich handhaben: wissen, was die Session garantiert, was nicht, und warum das deine RLS nicht schwächt.
Auth Magic-Link & Sessions
LektionAuthentifizierung per Magic-Link mit Supabase implementieren und die Session clientseitig ehrlich handhaben: wissen, was die Session garantiert, was nicht, und warum das deine RLS nicht schwächt.
MOONKEY LAB und Espejo nutzen Login ohne Passwort: der Nutzer gibt seine E-Mail ein, erhält einen Link, klickt und ist authentifiziert. Das ist die beste UX und eliminiert eine ganze Klasse von Schwachstellen (du speicherst keine Passwörter, es gibt keine Hash-Lecks). Aber die Session lebt im Browser, und ein Operator, der nicht versteht, wo und wie das Token gespeichert wird, verwechselt Bequemlichkeit mit Sicherheit.
DIE LEKTION
Wie der Magic-Link funktioniert, Schritt für Schritt. Der Client ruft `supabase.auth.signInWithOtp({ email })` auf. Supabase generiert ein Einmal-Token, verknüpft es mit der E-Mail und schickt eine Mail mit einem Link, der auf deine App mit diesem Token im URL-Fragment zeigt. Der Nutzer klickt; deine App erkennt beim Laden das Token, tauscht es bei Supabase gegen ein Paar access_token / refresh_token, und ab da ist der Client authentifiziert. Das access_token ist ein signiertes JWT mit kurzer Gültigkeit (standardmässig eine Stunde); das refresh_token ist langlebig und dient dazu, neue access tokens zu beziehen, ohne dass sich der Nutzer erneut einloggt. All das orchestriert das SDK; deine Aufgabe ist es, den Ablauf zu verstehen, nicht ihn neu zu implementieren.
Wo die Session lebt. Standardmässig persistiert das Supabase-SDK im Browser die Session in localStorage. Das hat eine Sicherheitskonsequenz, die du laut aussprechen musst: ein Token in localStorage ist von jedem JavaScript lesbar, das auf deiner Seite läuft. Das heisst, deine Angriffsfläche Nummer eins ist XSS — schafft es ein Angreifer, JS in deine Site einzuschleusen (ein kompromittiertes Drittanbieter-Skript, ein innerHTML mit ungesäubertem Input), kann er das Token lesen und die Session stehlen. Die Verteidigung ist nicht, das Token zu verstecken; sie ist, kein XSS zu haben: bring keinen ungesäuberten Nutzer-HTML ein, prüfe jede Frontend-Abhängigkeit und behandle jedes `<script>` von Drittanbietern als Code, der deine Tokens sehen wird.
Die ehrliche Unterscheidung, die dem Modul seinen Namen gibt: die Session ist Identität, nicht Autorisierung. Dass der Client ein gültiges access_token hat, beweist WER er ist (auth.uid() gibt seine uuid zurück), aber gibt ihm an sich KEINE Berechtigung für irgendetwas. Die Berechtigung entscheidet die RLS bei jeder Query. Das ist befreiend: du musst deine Daten nicht im Frontend verteidigen. Selbst wenn ein Angreifer eine Session stiehlt, kann er nur das tun, was die RLS DIESEM Nutzer erlaubt — seine eigenen Zeilen sehen, nicht die anderer. Die gestohlene Session eines normalen Nutzers gibt keinen Admin-Zugang, weil is_admin() gegen die profiles-Zeile revalidiert, nicht gegen einen Claim, den der Client manipulieren kann.
Handhabung des Session-Zustands in der App. Das SDK stellt `supabase.auth.getSession()` bereit (liest die aktuelle Session, möglicherweise aus dem localStorage) und `supabase.auth.onAuthStateChange((event, session) => ...)` (benachrichtigt dich über SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED). Bei einer SSG wie MOONKEY hast du kein serverseitiges Rendering des Auth-Zustands, also lädt die Seite zuerst im Zustand «unbekannt» und du löst dann clientseitig auf, ob eine Session existiert. Designe dafür: zeige einen neutralen Ladezustand, flackere nicht zwischen «Gast» und «eingeloggt». Das visuelle Gate (/cuenta nur bei Session zeigen) ist legitime UX — erinnere dich an DS-02: es ist UX, die Sicherheit bleibt in der RLS.
Der Trigger, der den Kreis schliesst. Wenn sich ein Nutzer zum ersten Mal per Magic-Link registriert, erstellt Supabase eine Zeile in auth.users. Aber deine App braucht eine entsprechende Zeile in profiles. Das macht NICHT der Client (er soll seine eigene role oder founder_badge nicht wählen können). Das macht ein SECURITY-DEFINER-Trigger in der Datenbank: `handle_new_user`, der `after insert on auth.users` auslöst und die profiles-Zeile mit sicheren Default-Werten erstellt (role='monkey', niemals admin). So ist die Profilerstellung serverautoritativ: der Nutzer kann nicht als Admin geboren werden, weil der Trigger, nicht der Client, die Anfangswerte entscheidet. Dieses Muster vertiefst du in DS-04.
Logout und Ablauf, ehrlich gemacht. `supabase.auth.signOut()` löscht die Tokens aus dem localStorage und widerruft das refresh_token auf dem Server. Wichtig: löschst du nur den localStorage von Hand, ohne signOut aufzurufen, bleibt das refresh_token auf dem Server gültig — mach es immer über das SDK. Zum Ablauf: versprich keine «Session für immer». Das access_token läuft in einer Stunde ab und das SDK erneuert es transparent mit dem refresh_token; wird das refresh_token widerrufen oder läuft ab, muss sich der Nutzer erneut einloggen. Kommuniziere das dem Nutzer ehrlich, statt ewige Persistenz vorzutäuschen.
Konfiguriere die Redirect-URLs im Supabase-Panel (Auth > URL Configuration). Der Magic-Link leitet auf eine URL um, die in der Allowlist stehen MUSS, sonst lehnt Supabase den Tausch ab — das verhindert, dass ein Angreifer den Link auf eine von ihm kontrollierte Domain umleiten lässt. In MOONKEY umfassen die Redirect-URLs die Produktionsdomain (moonkeylab.pages.dev) und localhost für die Entwicklung, und nichts weiter. Eine laxe Redirect-Allowlist ist ein echter Phishing-Vektor.
ÜBUNG
Implementiere in MOONKEY (oder einem lokalen Klon, der auf eine Supabase-Branch zeigt) den vollständigen Ablauf: eine Login-Seite, die signInWithOtp mit der E-Mail des Nutzers aufruft und «prüfe deine Mail» anzeigt; die Handhabung des Tauschs bei der Rückkehr vom Link; und eine /cuenta-Seite, die getSession + onAuthStateChange nutzt, um die E-Mail des eingeloggten Nutzers anzuzeigen oder zum Login umzuleiten, falls keine Session existiert. Verifiziere drei Dinge: (1) nach dem Klick auf den Magic-Link existiert eine vom Trigger handle_new_user erstellte Zeile in profiles mit role='monkey'; (2) signOut löscht die Session und widerruft das refresh token; (3) konfiguriere die Redirect-URLs im Panel und bestätige, dass ein Redirect auf eine NICHT gelistete Domain abgelehnt wird.
ERGEBNIS
Ein funktionierender Magic-Link-Login-Ablauf gegen eine Supabase-Branch, mit Screenshots von: der vom Trigger automatisch erstellten profiles-Zeile (role='monkey'), dem in /cuenta gelesenen Session-Zustand und dem Bildschirm Auth > URL Configuration mit der Allowlist der Redirect-URLs.
KERNEINSICHT
Die Session beweist, wer du bist, nicht was du tun darfst. Bricht deine Sicherheit zusammen, wenn jemand eine Session stiehlt, dann hast du dem Client das Autorisieren überlassen — und die Autorisierung muss immer in der RLS wohnen, wo eine gestohlene Session nur das öffnet, was DIESER Nutzer ohnehin sehen durfte.
ZU VERMEIDENDE FEHLER
- ×Das Token in localStorage als sicheres Geheimnis behandeln: es ist von jedem JS der Seite lesbar, also ist deine echte Verteidigung, kein XSS zu haben, nicht das Token zu verstecken.
- ×Eine Session zu haben mit eine Berechtigung zu haben verwechseln: die Session gibt Identität (auth.uid()), aber jeden Zugriff entscheidet weiterhin die RLS — autorisiere nie im Frontend.
- ×Die profiles-Zeile vom Client aus erstellen statt mit dem Trigger handle_new_user: du liessest den Nutzer seine eigene role wählen und öffnetest die Selbst-Eskalation zum Admin.
- ×Den localStorage von Hand löschen statt signOut aufzurufen: das refresh_token bleibt auf dem Server am Leben und die Session kann wieder aufgenommen werden.
- ×Die Allowlist der Redirect-URLs offen oder mit Wildcards lassen: das macht den Magic-Link zu einem Phishing-Vektor, der auf eine Domain des Angreifers umleitet.
DS-04 SECURITY DEFINER, RPC und Trigger
Lektion SECURITY-DEFINER-Funktionen, RPCs und Trigger schreiben, die privilegierte Logik kontrolliert ausführen, ohne Löcher für Privilege Escalation zu öffnen.
SECURITY DEFINER, RPC und Trigger
LektionSECURITY-DEFINER-Funktionen, RPCs und Trigger schreiben, die privilegierte Logik kontrolliert ausführen, ohne Löcher für Privilege Escalation zu öffnen.
Es gibt Operationen, die die RLS allein nicht ausdrücken kann: prüfen, ob jemand Admin ist (die Prüfung selbst muss profiles lesen, was eine Rekursion erzeugen würde), den Rang eines Operators unter Revalidierung der Regeln befördern, oder verhindern, dass sich ein Nutzer selbst das founder_badge setzt. SECURITY DEFINER ist das Werkzeug — und es ist genau die Stelle, an der du, falsch eingesetzt, die Hintertür öffnest, die deine gesamte RLS zu schliessen versuchte.
DIE LEKTION
Was SECURITY DEFINER bedeutet. Eine normale Funktion in Postgres läuft mit den Rechten desjenigen, der sie AUFRUFT (SECURITY INVOKER, der Default). Eine SECURITY-DEFINER-Funktion läuft mit den Rechten desjenigen, der sie ERSTELLT hat (typischerweise eine privilegierte Rolle, Eigentümer der Tabellen). Das erlaubt ihr, Dinge zu tun, die der Aufrufer nicht direkt tun könnte — etwa profiles lesen, um eine Rolle zu prüfen, obwohl die RLS dem Nutzer dieses SELECT verweigern würde. Es ist mächtig und gefährlich: eine SECURITY DEFINER ist ein kleines Stück Code, das oberhalb der RLS läuft. Jede einzelne ist eine Ausnahme von deinem Sicherheitsmodell, also muss jede einzelne als solche auditiert werden.
Der Fall is_admin(). Du musst wissen, ob der aktuelle Nutzer Admin ist, um es in Policies zu verwenden (`using (... or is_admin())`). Aber wenn die SELECT-Policy von profiles davon abhängt, profiles zu lesen, um die Rolle zu kennen, hast du eine endlose Rekursion: um deine Zeile zu lesen, musst du wissen, ob du Admin bist, um das zu wissen, liest du profiles, was die Policy erneut auslöst. Die Lösung ist is_admin() als SECURITY DEFINER: läuft mit den Rechten des Eigentümers, liest profiles OHNE durch die RLS zu gehen, gibt einen Boolean zurück. Entscheidend ist, dass sie KEINE sensiblen Daten zurückgibt — nur true/false über den Aufrufer (`select role = 'admin' from profiles where id = auth.uid()`). Eine sichere SECURITY DEFINER legt die minimale Information offen: eine Entscheidung, kein Dataset.
Den search_path absichern — das ist die klassische Schwachstelle und die, die die Advisors gnadenlos markieren. Eine SECURITY DEFINER, die ihren search_path nicht festlegt, ist ausnutzbar: ein Angreifer erstellt eine Tabelle oder Funktion mit demselben Namen wie eine, die deine Funktion verwendet, in einem Schema, das früher im search_path steht, und deine privilegierte Funktion führt den Code des Angreifers mit Eigentümerrechten aus. Die Verteidigung ist Pflicht: `create function is_admin() ... security definer set search_path = '' as $$ ... $$;` (oder `set search_path = pg_catalog, public` mit expliziter Qualifizierung). Mit leerem search_path referenzierst du jede Tabelle mit ihrem vollständigen Schema: `public.profiles`, nicht `profiles`. Ohne das IST deine Sicherheitsfunktion das Loch.
RPCs: vom Client aufrufbare Geschäftslogik. Ein RPC in Supabase ist eine Postgres-Funktion, die über `supabase.rpc('name', args)` exponiert wird. In MOONKEY sind die echten RPCs update_operator_rank (befördert den Rang unter Revalidierung), my_referral_stats (gibt die Empfehlungsstatistiken des Nutzers zurück) und is_admin. Die goldene Regel: der RPC VERTRAUT den Argumenten des Clients nicht bezüglich der Identität. update_operator_rank erhält «welchen Nutzer befördern» nicht als freien Parameter — es verwendet intern auth.uid(). Erhielte es eine user_id als Argument, würde ein Angreifer rpc('update_operator_rank', { user_id: 'die eines anderen' }) aufrufen. Die Identität kommt IMMER aus auth.uid() innerhalb der Funktion, niemals aus einem Parameter, den der Client kontrolliert.
Der RPC revalidiert, er gehorcht nicht. update_operator_rank ist nicht «setz meinen Rang auf X, weil ich es verlange». Es revalidiert die Regeln: hat der Nutzer die für diesen Rang erforderlichen Nachweise erbracht? Der Kommentar im Sicherheitsmodell von MOONKEY ist explizit — «Ränge nur über update_operator_rank (revalidiert die Rolle)». Der Client kann nicht von monkey zu admin springen, indem er es verlangt; der RPC prüft die echten Bedingungen in der DB und schreibt erst dann. Das ist der Unterschied zwischen einem RPC, der eine Geschäfts-API ist (prüft Invarianten), und einem, der ein Loch ist (schreibt, was man ihm sagt). Erteile EXECUTE nur an `authenticated`, niemals an anon: `grant execute on function update_operator_rank to authenticated`.
Trigger: Invarianten, die durchgesetzt werden, egal was passiert. Manche Invarianten dürfen nicht davon abhängen, dass die App sie respektiert. «Ein Nicht-Admin darf nie seine eigene role oder sein founder_badge ändern» ist eine davon: hinge sie von der App ab, würde jedes direkte UPDATE über die anon key sie umgehen. Die Lösung von MOONKEY ist der Trigger guard_privileged_profile_columns, der `before update on profiles` auslöst und, wenn der Aufrufer kein Admin ist und versucht, role oder founder_badge zu ändern, eine Exception wirft, die die Transaktion abbricht. Kombiniert mit handle_new_user (after insert on auth.users, erstellt profiles mit role='monkey') ist das Ergebnis, dass ein Nutzer als monkey GEBOREN wird und sich NICHT selbst befördern kann — weder über die App noch über ein rohes UPDATE mit der anon key. Der Trigger ist das Netz unter der RLS.
Die Audit-Disziplin. Jede SECURITY DEFINER, die du schreibst: hat sie den search_path festgelegt? Gibt sie die minimal mögliche Information zurück? Leitet sie die Identität aus auth.uid() ab und nicht aus einem Parameter? Ist ihr EXECUTE auf die richtige Rolle beschränkt? Nach dem Erstellen oder Ändern einer dieser Funktionen führst du get_advisors (DS-06) aus: der Sicherheits-Advisor markiert SECURITY DEFINER ohne search_path und Funktionen mit laxen Berechtigungen. Eine SECURITY DEFINER ist privilegierter Code; behandle sie mit der Paranoia, die Code verdient, der oberhalb deiner eigenen Sicherheit läuft.
ÜBUNG
Auf einer Branch von moonkey-lab: (1) Schreibe is_admin() als SECURITY DEFINER mit `set search_path = ''`, die public.profiles liest und einen Boolean über auth.uid() zurückgibt. (2) Schreibe einen before-update-Trigger auf profiles, der abbricht, wenn ein Nicht-Admin versucht, role oder founder_badge zu ändern, und implementiere ihn, indem du admin aus is_admin() ableitest. (3) Teste den Missbrauch: versuche mit einer normalen (impersonierten) Nutzersession `update profiles set role='admin' where id=auth.uid()` und verifiziere, dass der Trigger eine Exception wirft. (4) Führe get_advisors(type='security') aus und bestätige, dass keine Warnung über mutablen search_path auf deinen Funktionen erscheint.
ERGEBNIS
Migration mit is_admin() (SECURITY DEFINER, search_path festgelegt) und dem guard-Trigger auf profiles, plus Belege für: das vom Trigger abgelehnte Selbst-Eskalations-UPDATE und ein sauberes get_advisors für Sicherheit (keine search_path-Warnungen).
KERNEINSICHT
SECURITY DEFINER ist der einzige Teil deines Systems, der oberhalb der RLS läuft, also der einzige Ort, an dem eine Nachlässigkeit zu einem Totalbruch eskaliert. Die nicht verhandelbare Minimalregel: search_path festgelegt, Identität aus auth.uid() und nie aus Parametern, und EXECUTE-Berechtigungen beschränkt — denn hier gibt es kein zweites Netz.
ZU VERMEIDENDE FEHLER
- ×Eine SECURITY DEFINER ohne `set search_path` erstellen: das lässt einen Angreifer Tabellen-/Funktionsnamen kapern und seinen Code mit Eigentümerrechten ausführen — die Advisors markieren das nicht ohne Grund.
- ×Die user_id als RPC-Argument übergeben statt intern auth.uid() zu verwenden: der Client würde den RPC mit der id eines anderen Nutzers aufrufen und in dessen Namen operieren.
- ×update_operator_rank dem verlangten Rang gehorchen lassen statt die Bedingungen zu revalidieren: das macht die Beförderung zur Selbst-Eskalation per Klick.
- ×Der App vertrauen, dass sie verhindert, dass ein Nutzer seine role ändert: ein direktes UPDATE mit der anon key würde das umgehen; die Invariante muss in einem Trigger wohnen.
- ×EXECUTE der RPCs an anon oder public erteilen statt nur an authenticated: du legst privilegierte Geschäftslogik Anfragen ohne Session offen.
DS-05 Sync lokal ↔ Cloud
Lektion Eine Synchronisation lokal↔Cloud entwerfen, bei der der in localStorage lebende Zustand nach Postgres hochgeladen wird, ohne Daten zu verlieren oder Duplikate zu erzeugen, und Konflikte deterministisch löst.
Sync lokal ↔ Cloud
LektionEine Synchronisation lokal↔Cloud entwerfen, bei der der in localStorage lebende Zustand nach Postgres hochgeladen wird, ohne Daten zu verlieren oder Duplikate zu erzeugen, und Konflikte deterministisch löst.
Espejo startet mit seinem Zustand in localStorage (sein Store-Seam ist darauf ausgelegt, von localStorage zu Supabase zu wechseln, ohne die App neu zu schreiben) und MOONKEY persistiert den Operator-Fortschritt, der zuerst im Browser existiert und dann in die Cloud hochgeladen werden muss, wenn sich der Nutzer einloggt. Ist die Sync naiv, verliert ein Nutzer, der offline vorankam und sich dann einloggt, seinen Fortschritt, oder dupliziert ihn, oder überschreibt, was er auf einem anderen Gerät hatte.
DIE LEKTION
Das eigentliche Problem ist nicht «Daten kopieren». Es ist, zwei Quellen der Wahrheit zu versöhnen, die sich getrennt entwickelt haben: der localStorage dieses Browsers und die Zeile in Postgres (die sich von einem anderen Gerät aus geändert haben könnte). Bevor du Code schreibst, entscheide die Konfliktpolitik explizit, denn «was halt passiert» ist das Rezept für Datenverlust. Die ehrlichen Optionen: last-write-wins (der jüngste Timestamp gewinnt, einfach, kann aber gleichzeitige Edits verlieren), Merge pro Feld (du kombinierst Feld für Feld nach Regeln) oder append-only (du überschreibst nie, fügst nur hinzu und leitest den Zustand ab). Für Lernfortschritt — der monoton ist, nur vorwärts geht — ist die beste Politik meist «das Maximum gewinnt»: sagt lokal Modul 3 abgeschlossen und Cloud Modul 5, ist das Ergebnis 5; du gehst nie zurück.
Der localStorage als Schicht, nicht als Wahrheit. Das Muster des Store-Seam von Espejo ist die richtige Abstraktion: deine App ruft nicht direkt localStorage oder Supabase auf, sie ruft einen Store mit einer Schnittstelle auf (get, set, list). Es gibt eine Implementierung LocalStore (localStorage) und eine SupabaseStore (Postgres). Die App weiss nicht, welche sie nutzt. Das macht aus «in die Cloud wechseln» eine Neuimplementierung hinter derselben Schnittstelle statt eine Neuschrift. Die Sync ist dann eine Operation zwischen zwei Stores: alles aus LocalStore lesen, mit SupabaseStore versöhnen, das Ergebnis in beide schreiben. Baue den Seam, BEVOR du die Cloud brauchst; am Anfang ist er billig und nachträglich nachzurüsten extrem teuer.
Idempotenz ist die Eigenschaft, die dich rettet. Die Sync wird unterbrochen werden: der Nutzer schliesst den Tab mittendrin, das Netz fällt aus, das SDK versucht es erneut. Ist deine Sync nicht idempotent, erzeugt ein zweiter Durchlauf Duplikate. Das Werkzeug ist der Upsert mit natürlichem Schlüssel: `insert into progress (user_id, module_code, completed_at) values (...) on conflict (user_id, module_code) do update set completed_at = greatest(progress.completed_at, excluded.completed_at)`. Genau diese unique (user_id, module_code) aus DS-01 macht den Upsert möglich. Führe die Sync zweimal hintereinander aus: ist der Endzustand identisch, ist sie idempotent. Dupliziert die zweite Ausführung Zeilen oder ändert etwas, hast du einen Bug, der sich in Produktion als korrupte Daten um Mitternacht zeigt.
Der kritische Moment: das erste Login nach der Arbeit als Gast. Der Nutzer kam in localStorage ohne Session voran, macht dann den Magic-Link (DS-03) und erhält ein auth.uid(). Jetzt muss der anonyme Zustand unter seiner Identität adoptiert werden. Der sichere Ablauf: wenn onAuthStateChange mit SIGNED_IN auslöst, liest du den Fortschritt aus LocalStore, lädst ihn per Upsert hoch und bindest user_id = auth.uid(), und erst dann markierst du das Lokale als synchronisiert. Lösche das Lokale NICHT, bevor du bestätigt hast, dass die Cloud es empfangen hat (die Bestätigung ist die fehlerfreie Antwort des Upserts). Löschst du vorher und der Upload scheitert, hast du die Daten verloren. Reihenfolge: hochladen, bestätigen, als synchronisiert markieren, optional aufräumen.
Die RLS bestimmt weiterhin während der Sync. Wenn du den Fortschritt mit der Session des Nutzers hochlädst, geht der Upsert mit seinem JWT, also greift die Policy `with check (user_id = auth.uid())`: du kannst keinen Fortschritt im Namen eines anderen hochladen, auch wenn der localStorage etwas anderes sagte. Das ist gut — die Sync ist keine Hintertür zur Sicherheit. Es bedeutet, dass du user_id im Moment des Hochladens an auth.uid() binden musst, nicht eine user_id verwenden, die du aus dem anonymen Zustand (der keine echte Identität hatte) mitschleppst. Die Sync respektiert das Modell: der Server bleibt die Autorität darüber, wem jede Zeile gehört.
Konflikte zwischen Geräten, der Fall, den man vergisst. Der Nutzer kommt auf dem Handy voran (lädt in die Cloud), öffnet dann das Notebook, das einen alten lokalen Zustand hatte. Ohne Vorsicht überschreibt das Notebook die Cloud mit veralteten Daten. Die Verteidigung: die Versöhnung ist nicht «lokal übertrumpft Cloud», sie ist «beide nach der Politik versöhnen». Für monotonen Fortschritt holst du die Cloud, machst den Merge «das Maximum gewinnt» mit dem Lokalen und schreibst das Ergebnis in beide. So lernt das Notebook, was das Handy getan hat, statt es zu löschen. Für nicht-monotone Daten brauchst du Timestamps pro Feld (updated_at) und last-write-wins pro Feld, was verlangt, diese Timestamps von Anfang an zu speichern — ein weiterer Grund für die timestamptz aus DS-01.
Sync-Zustände, sichtbar und ehrlich. Modelliere explizit: synced (lokal == Cloud), pending (es gibt nicht hochgeladene lokale Änderungen), syncing (läuft gerade), error (gescheitert, erneut versuchen). Lüge den Nutzer nicht mit einem grünen Häkchen an, wenn der Upload scheiterte. Ein ehrlicher «ungespeicherte Änderungen»-Indikator verhindert, dass der Nutzer den Tab schliesst im Glauben, er sei in Sicherheit. Die stille Sync, die still scheitert, ist schlimmer als gar keine Sync: der Nutzer vertraut und verliert Daten, ohne es zu wissen.
ÜBUNG
Auf der Tabelle progress (user_id, module_code, completed_at, mit unique(user_id, module_code)): implementiere einen Store-Seam mit zwei Backends, LocalStore (localStorage) und SupabaseStore. Schreibe eine Funktion sync(), die: den lokalen Fortschritt liest, ihn mit dem der Cloud versöhnt nach 'das grösste completed_at gewinnt' per Upsert mit `on conflict do update set completed_at = greatest(...)`, und user_id = auth.uid() bindet. Teste drei Szenarien: (1) Idempotenz — führe sync() zweimal aus und verifiziere identischen Endzustand, null Duplikate; (2) erstes Login — komm als Gast voran, logg dich ein, und bestätige, dass der anonyme Fortschritt unter deiner user_id in Postgres erscheint; (3) zwei Geräte — simuliere Cloud mit Modul 5 und lokal mit Modul 3, führe sync aus und verifiziere, dass das Ergebnis auf beiden Seiten 5 ist (kein Rückschritt).
ERGEBNIS
Ein Store-Modul mit gemeinsamer Schnittstelle und zwei Implementierungen (LocalStore/SupabaseStore) plus eine idempotente sync()-Funktion auf Upsert-Basis, mit einem Log der drei Szenarien: doppelte Ausführung ohne Duplikate, Adoption des anonymen Zustands beim Login und Versöhnung «das Maximum gewinnt» zwischen zwei Geräten ohne Verlust.
KERNEINSICHT
Sync ist nicht Daten kopieren, es ist zwei Quellen der Wahrheit versöhnen, die auseinandergedriftet sind — und die einzige Art, nichts zu verlieren, ist, die Konfliktpolitik explizit zu wählen und die Operation idempotent zu machen mit einem Upsert auf natürlichem Schlüssel. Wenn du deine Sync nicht zweimal hintereinander mit demselben Ergebnis ausführen kannst, hast du keine Sync, du hast eine Zeitbombe.
ZU VERMEIDENDE FEHLER
- ×Den localStorage löschen, bevor bestätigt ist, dass der Upload in die Cloud erfolgreich war: scheitert der Upsert, sind die Daten für immer verloren.
- ×Nicht-idempotente Sync ohne Upsert auf natürlichem Schlüssel: ein unterbrochener und erneut versuchter Durchlauf dupliziert Zeilen, die als Korruption zur Unzeit auftauchen.
- ×Das Gerät mit altem Zustand die Cloud übertrumpfen lassen ('lokal gewinnt') statt zu versöhnen: das Notebook löscht, was das Handy vorangebracht hat.
- ×Die user_id aus dem anonymen Zustand mitschleppen statt sie beim Hochladen an auth.uid() zu binden: die WITH-CHECK-Policy lehnt sie ab, oder schlimmer, du versuchst unter einer Identität zu schreiben, die nicht die echte ist.
- ×Ein grünes 'synchronisiert'-Häkchen zeigen, wenn der Upload scheiterte: der Nutzer vertraut, schliesst den Tab und verliert die Arbeit, ohne es zu wissen.
DS-06 Advisors, Migrationen und Audit
Lektion Die Datenbank mit versionierten Änderungen über Migrationen betreiben und die Supabase-Advisors als kontinuierlichen Sicherheits-Linter nutzen, der dich vor Tabellen ohne RLS, Funktionen ohne search_path und anderen Löchern warnt, bevor sie in Produktion gelangen.
Advisors, Migrationen und Audit
LektionDie Datenbank mit versionierten Änderungen über Migrationen betreiben und die Supabase-Advisors als kontinuierlichen Sicherheits-Linter nutzen, der dich vor Tabellen ohne RLS, Funktionen ohne search_path und anderen Löchern warnt, bevor sie in Produktion gelangen.
MOONKEY teilt sich die Datenbank mit XHUB IRON: eine unachtsame Änderung kann Tabellen eines anderen Projekts überschreiben oder eine neue Tabelle ohne RLS offen für die anon key lassen. Ohne versionierte Migrationen gibt es keine Möglichkeit zu wissen, was sich geändert hat, oder es rückgängig zu machen; ohne die Advisors entdeckst du das Sicherheitsloch erst, wenn es jemand bereits ausgenutzt hat. Das ist die Disziplin, die alles Vorherige ehrlich hält.
DIE LEKTION
Migrationen: die Datenbank als versionierter Code. Jede Schemaänderung — eine Tabelle, eine Spalte, eine Policy, eine Funktion — ist eine .sql-Datei mit Timestamp in supabase/migrations/, angewendet mit apply_migration, niemals von Hand mit execute_sql. Der Unterschied ist derselbe wie zwischen committen und Dateien in Produktion per SSH editieren: das eine gibt dir Historie, Review und Rollback; das andere gibt dir Amnesie. Der Dateiname (`<timestamp>_create_proofs.sql`, `<timestamp>_add_proofs_rls.sql`) erzählt die Geschichte des Schemas. Wenn du dich in sechs Monaten fragst, warum eine Spalte existiert, steht die Antwort in der Migration, die sie einführte, mit Name und Datum — nicht in deinem Gedächtnis oder dem von irgendwem.
execute_sql ist zum LESEN, apply_migration ist zum ÄNDERN. Diese operative Regel verhindert den häufigsten Fehler. Verwende execute_sql zum Inspizieren (select, explain, Zustand prüfen), zum Impersonieren und Verifizieren der RLS (DS-02), zum Explorieren. In dem Moment, in dem das SQL das Schema oder die Policies so verändert, dass es persistieren soll, gehört es in eine Migration. Eine Sicherheitsänderung, die mit execute_sql angewendet wurde, funktioniert, aber nicht versioniert ist, ist Schuld: niemand weiss, dass sie existiert, niemand kann sie reviewen, und beim Neuaufbau des Projekts verschwindet sie.
Supabase-Branches, um Produktion nicht zu zerstören. Bevor du eine folgenreiche Migration anwendest, erstelle sie in einer Branch (create_branch), teste sie dort — inklusive der Impersonations-Verifikation aus DS-02 und dem get_advisors aus DS-04 — und merge sie erst dann nach Produktion (merge_branch). Die Branch hat ihre eigene flüchtige Datenbank; du zerstörst, was du willst, ohne echte Nutzer anzufassen. Das ist in MOONKEY besonders kritisch, weil die DB geteilt ist: eine Migration, die versehentlich eine iron_*- oder world_*-Tabelle von XHUB anfasst, wird in der Branch getestet und verworfen, nicht in der lebenden Datenbank, die zwei Projekte bedient.
Die Advisors sind dein Sicherheits-Linter. get_advisors(type='security') führt eine Reihe von Prüfungen aus, die genau die Löcher erkennen, die diese Module zu vermeiden lehren: Tabellen mit deaktivierter RLS (DS-02), SECURITY-DEFINER-Funktionen mit mutablem search_path (DS-04), Policies, die Daten offenlegen, ungeschützte Spalten. get_advisors(type='performance') markiert das andere: Foreign Keys ohne Index, doppelte Indizes, nicht abgedeckte Queries. Die nicht verhandelbare Disziplin aus dem CLAUDE.md von MOONKEY: 'RLS-/Sicherheitsänderungen: als versionierte Migration anwenden + danach get_advisors'. Jedes Mal, wenn du Sicherheit anfasst, ist der Advisor der Schluss des Kreislaufs — du nimmst nicht an, dass es gut ist, du verifizierst es mit dem Werkzeug.
Wie man einen Advisor liest und handelt. Eine Advisor-Warnung ist kein Rauschen zum Stummschalten; sie ist eine konkrete Schwachstelle mit konkreter Behebung. 'RLS disabled on public.proofs' bedeutet, dass jeder mit der anon key die Tabelle liest — die Behebung ist enable row level security + Policies, in einer Migration. 'Function public.is_admin has a role mutable search_path' bedeutet, dass die Funktion kaperbar ist — die Behebung ist `alter function ... set search_path = ''`, in einer Migration. Die reife Routine: du fasst etwas an → Migration → get_advisors → bei einer Warnung eine weitere Migration, die sie schliesst → sauberes get_advisors. Es gibt kein 'mach ich später' bei Sicherheits-Advisors; später ist nach dem Bruch.
Isolation in der geteilten DB, das spezifische Risiko von MOONKEY. Die Datenbank beherbergt Tabellen von MOONKEY (profiles, progress, feedback, leads, proofs) und von XHUB IRON (iron_*, world_*, focus_*, daily_focus_history). Deine Migrationsdisziplin muss diese Grenze respektieren: eine MOONKEY-Migration darf NIEMALS, auch nicht durch ein nachlässiges DROP oder ein zu breites ALTER, eine Tabelle des anderen Projekts verändern. Bevor du anwendest, lies das Diff der Migration wie ein Gegner: fasst es nur die Tabellen an, die ich genannt habe? Der Advisor und das SQL-Review sind die beiden Netze. In einer geteilten DB ist eine schlecht abgegrenzte Änderung nicht ein Bug von dir, sie ist ein Vorfall eines anderen Projekts.
Auditing als Gewohnheit, nicht als Ereignis. Auditing ist nicht etwas, das du vor einem Launch machst; es ist der Standardzustand des ernsthaften Datenbetriebs. list_migrations gibt dir die vollständige Historie, wie das Schema dahin kam, wo es ist. get_logs zeigt dir, was in Echtzeit scheitert. get_advisors ist der Gesundheitscheck, den du nach jeder Änderung und periodisch ausführst, selbst wenn du nichts änderst (weil Supabase neue Prüfungen hinzufügt und weil sich der Kontext ändert). Der Operator, der die Datenbank als lebendes System behandelt, das kontinuierlich auditiert wird, ist der, der nicht den 3-Uhr-Anruf bekommt — weil er die Warnung in der Branch sah, eine Woche vorher, mit get_advisors.
ÜBUNG
Nimm die RLS-Migration von proofs, die du in DS-02 geschrieben hast, aber diesmal mit voller Disziplin: (1) erstelle eine Branch von moonkey-lab; (2) wende darauf, als getrennte versionierte Migrationen mit beschreibenden Namen, die Erstellung der Tabelle und ihre Policies an; (3) führe get_advisors(type='security') VOR den Policies aus und bestätige, dass die Warnung 'RLS disabled' über proofs erscheint; (4) wende die Policies an und führe get_advisors erneut aus, und bestätige, dass die Warnung verschwindet; (5) führe absichtlich eine SECURITY-DEFINER-Funktion ohne search_path ein, prüfe, dass der Advisor sie markiert, behebe sie mit set search_path='' in einer weiteren Migration und bestätige einen sauberen Advisor; (6) merge die Branch nach Produktion nur bei grünem Advisor. Dokumentiere die finale Migrationsliste mit list_migrations.
ERGEBNIS
Eine Branch mit versionierten und benannten Migrationen für Tabelle+Policies+Funktion, plus eine Sequenz von get_advisors-Ausgaben, die den Zyklus des Warnungs-Schliessens belegt: 'RLS disabled' vorhanden → nach den Policies abwesend, 'mutable search_path' vorhanden → nach dem Fix abwesend, und ein finales sauberes get_advisors vor dem Merge nach Produktion.
KERNEINSICHT
Die Advisors verwandeln dein Sicherheitsmodell von etwas, von dem du glaubst, es gut gemacht zu haben, in etwas, von dem das Werkzeug bestätigt, dass es gut ist. Die Regel des ernsthaften Operators: keine Sicherheitsänderung gilt als beendet, bis get_advisors sauber ist — denn der Preis einer ignorierten Warnung ist nicht eine Warnung, er ist ein Bruch, den du auf die harte Tour entdeckst.
ZU VERMEIDENDE FEHLER
- ×Schema- oder Policy-Änderungen mit execute_sql statt apply_migration anwenden: du verlierst Historie, Review und Rollback, und die Änderung verschwindet beim Neuaufbau des Projekts.
- ×Die geteilte Produktionsdatenbank direkt anfassen statt in einer Branch zu testen: ein zu breites ALTER oder DROP wird zu einem Vorfall für XHUB IRON.
- ×get_advisors nach einer Sicherheitsänderung überspringen: du lässt genau die Tabelle ohne RLS oder die Funktion ohne search_path am Leben, die der Advisor in Sekunden markiert hätte.
- ×Eine Advisor-Warnung als Rauschen zum Stummschalten behandeln statt als Schwachstelle mit konkreter Behebung: 'mach ich später' in der Sicherheit ist 'mach ich nach dem Bruch'.
- ×Das Diff der Migration vor dem Anwenden in einer geteilten DB nicht wie ein Gegner lesen: eine schlecht abgegrenzte Änderung überschreibt iron_*/world_*-Tabellen eines anderen Projekts, ohne dass du es merkst.
Nächste Konstellation
Builders
Produkt ausliefern