02Datenbanken5 min

Warum ich RLS in Postgres beide Male entfernt habe, die ich es benutzt habe

Row Level Security wirkt wie die richtige Standardpraxis. Mit Drizzle, Server Actions und eigener Auth lebt die Berechtigungslogik schon woanders, und beide zu pflegen tut weh.

Von Salva Castells

Titelbild: Paulo Humberto, CC BY-SA 3.0.
In diesem Beitrag

In meinem Profil-Schema gab es eine Policy wie diese, geschrieben mit dem Drizzle-Helper für Neon:

crudPolicy({
  role: authenticatedRole,
  read: authUid(table.userId),
  modify: false,
})

Drei Zeilen. Sie sagen, dass ein authentifizierter Nutzer nur seine Zeilen sieht und sie nicht ändern kann. Dagegen lässt sich schwer argumentieren. Anderthalb Jahre später habe ich diese drei Zeilen gelöscht, und die der Feedback-Tabelle, im selben Commit. Monate später habe ich die Löschung in einem anderen Projekt wiederholt, das dasselbe Schema geerbt hatte.

Das war keine Laune. Es lag daran, dass die Policy und der Code dasselbe sagten, und wenn zwei Stellen dasselbe sagen, lügt am Ende eine von beiden.

Das Versprechen von RLS und warum ich es gekauft habe

Das Versprechen ist gut: Die Berechtigung lebt in der Datenbank, also ist egal, wer abfragt. Wenn ich morgen ein Wartungsskript schreibe oder einen neuen Endpoint aufmache oder jemand ein where vergisst, kommt die Zeile nicht heraus. Das ist echte Defense in Depth, kein Zierrat.

Ich habe alles darum herum aufgebaut. Zwei Verbindungen:

export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
  const authToken = (await user?.getAuthJson())?.accessToken;
  const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
  const sql = neon(url, { authToken });
  return drizzle(sql);
};

Eine "authentifizierte" Verbindung, die das JWT des Nutzers mitführt und die Policies respektiert, und eine Admin-Verbindung mit der normalen DATABASE_URL, die sie umgeht. Ich bin sogar so weit gegangen, eine Map von RLS-Verbindungen pro Nutzer-ID zu cachen, bis mir auffiel, dass ich Verbindungen speicherte, die an ein ablaufendes Token gebunden sind, und ich es dabei beließ, jedes Mal eine neue zu erzeugen.

Wo es mit Drizzle und Server Actions zu knirschen begann

Das Problem ist nicht, dass RLS versagt. Es ist, dass es in einer App mit Next und Server Actions fast nie die entscheidende Schicht ist.

Jede Server Action beginnt bereits damit, die Session aufzulösen. Du hast den Nutzer schon, bevor du die Datenbank anfasst. Und dann schreibst du das hier:

const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));

Dieses where ist redundant zur Policy. Aber du kannst es nicht weglassen, denn wenn du es weglässt, lügt der Code: Es sieht aus, als hole er das gesamte Feedback, und er hängt von einer unsichtbaren Policy ab, die in einer anderen Datei steht. Also landest du mit dem Filter im Code und in der Datenbank, und mit dem Zweifel, welcher von beiden tatsächlich wirkt.

Das Zweite ist die Reibung im Alltag:

  • Zwei Verbindungen. Jede neue Funktion beginnt mit der Frage, ob sie über RLS oder über Admin läuft. Und alles, was Administration, Zahlungs-Webhooks, Cronjobs oder Migrationen ist, läuft über Admin. Also: über den Weg, der nichts prüft.
  • Die Fehler erklären nichts. Wenn die Policy dich blockiert, sagt sie dir nicht "dir fehlt der Claim"; sie gibt dir null Zeilen zurück. Ein select zu debuggen, das ein leeres Array liefert, obwohl die Zeile existiert, kostet mehr, als es aussieht.
  • Das Token reist dorthin, wo du es nicht wolltest. Die authentifizierte URL landet in einer Variable mit NEXT_PUBLIC_-Präfix, und das zwingt schon dazu, zweimal darüber nachzudenken, was du exponierst.
  • Die Policies leben im Snapshot. Eine Policy zu ändern heißt, eine Migration zu generieren und anzuwenden. Eine Prüfung im Code zu ändern heißt, eine Zeile zu ändern.

Tatsächlich habe ich irgendwann Drizzle komplett umgangen und Feedback und Profile über PostgREST sprechen lassen, genau um mich wirklich auf RLS zu stützen. Da wurde der Preis deutlich: Ich verlor die Typisierung, musste die Spaltennamen von Hand in snake_case schreiben (user_id, created_at), und jeder Aufruf endete im selben Ritual.

const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];

Ein as Feedback[] am Ende. Also darauf vertrauen, dass die Datenbank zurückgibt, was ich behaupte. Das war zwei Wochen später wieder Drizzle.

Derselbe Ausgang im zweiten Projekt

Als ich das nächste Projekt auf demselben Boilerplate startete, kamen die Policies mit. Sie hielten so lange, wie ich brauchte, um die Feedback-Tabelle ernsthaft anzufassen. Der Commit, in dem ich sie entfernt habe, tat gleichzeitig noch etwas anderes: feedback.description zu feedback.content umbenennen. Ein dummes Detail, aber es erzählt die ganze Geschichte: Ich gab dem Datenmodell gerade Form, und die Policies waren das, was mich beim Verschieben störte. In diesem Commit verschwanden der Import von drizzle-orm/neon und die vier Aufrufe von crudPolicy. Den Tabellen blieb das Einzige, was wirklich jeden Tag benutzt wird:

(table) => [index("idx_feedback_user_id").on(table.userId)]

Ein Index auf user_id. Den fragt Postgres bei jeder Anfrage tatsächlich ab.

Wo meine Berechtigungslogik heute lebt

In der Middleware und in der Schicht der Server Actions. Als ich die verwaltete Auth aufgegeben und mir meine eigene Session-Middleware geschrieben habe, landete die Stelle, an der entschieden wird, wer was sieht, an einem einzigen Punkt: Die Session wird aufgelöst, die Rolle geprüft, und von da an werden alle Queries mit der userId bereits in der Hand geschrieben. Eine einzige Verbindung, die Admin-Verbindung, und kein paralleler privilegierter Weg.

Auf dem Papier ist das weniger sicher. Eine Query, die das where vergisst, gibt fremde Zeilen zurück. Ich gleiche das damit aus, dass diese Queries alle in einem Actions-Verzeichnis leben und nicht über die App verstreut sind, und damit, dass die Signatur der Funktion dich zwingt, den Nutzer zu übergeben.

Wann ich RLS doch wieder benutzen würde

Wenn die Datenbank mehr als einen Client hat. Wenn es neben meiner Anwendung ein öffentliches PostgREST gibt, einen Client, der sich direkt verbindet, oder Analysten mit Lesezugriff, ist die Policy der einzige Ort, an dem die Regel für alle gilt. Da zahlen sich die Kosten für zwei Wahrheiten von selbst.

Auch in hartem Multi-Tenant, wo der Fehler nicht "du siehst fremdes Feedback" lautet, sondern "du siehst die Daten eines anderen Unternehmens". Da will ich das Netz darunter, auch wenn der Code schon filtert.

Für eine Anwendung mit einem einzigen Client, mit Server Actions und dem vor der ersten Query aufgelösten Nutzer ist RLS eine zweite Kopie einer Regel, die du schon geschrieben hast. Und eine zweimal geschriebene Regel ist nicht doppelt so sicher: Sie ist doppelt so leicht veralten zu lassen.