Zum Inhalt springen

Sicherheitsregeln (RLS)

Mit Sicherheitsregeln können Sie Row Level Security (RLS)-Richtlinien für Ihre PostgreSQL-Tabellen direkt in Ihren Collection-Definitionen festlegen. Wenn das Drizzle-Schema generiert wird, erstellt Rebase die entsprechenden CREATE POLICY-Anweisungen.

import { defineCollection } from "@rebasepro/cms-types";
const postsCollection = defineCollection({
slug: "posts",
name: "Posts",
table: "posts",
properties: { /* ... */ },
securityRules: [
{ operation: "select", access: "public" },
{ operations: ["insert", "update", "delete"], ownerField: "authorId" }
]
});
  1. Sie definieren securityRules in einer Collection
  2. rebase schema generate erstellt das Drizzle-Schema mit aktiviertem RLS
  3. rebase db push oder rebase db migrate wendet die Richtlinien auf PostgreSQL an
  4. Jede Abfrage wird automatisch nach dem Kontext des aktuellen Benutzers gefiltert

Die Identität des authentifizierten Benutzers ist in SQL über Folgendes verfügbar:

Funktion Rückgabewert
rebase.uid() Die ID des aktuellen Benutzers
rebase.roles() Kommagetrennte App-Rollen-IDs
rebase.jwt() Vollständige JWT-Claims als JSONB

Diese werden vom Rebase-Backend automatisch pro Transaktion gesetzt.

Policies, die eine Collection erhält, ohne sie zu schreiben

Abschnitt betitelt „Policies, die eine Collection erhält, ohne sie zu schreiben“

Die Policies einer Tabelle sind mehr als die securityRules der Collection selbst:

  • defaultSecurityRules, exportiert aus config/collections/index.ts. Eine Collection, deren securityRules fehlt oder leer ist, übernimmt sie alle an deren Stelle, als hätte sie sie selbst geschrieben. Eine Collection, die auch nur eine eigene Regel deklariert, übernimmt keine: Die beiden Listen werden nie zusammengeführt. Sie werden überall angewendet, wo das Verzeichnis gelesen wird: rebase dev, rebase db push, das Admin-Panel und ein Backend, dem collectionsDir übergeben wird. Der Index des Scaffolds wendet sie außerdem auf das collections-Array an, das er exportiert, sodass auch ein Backend, dem dieses Array übergeben wird, sie erhält. Ein auf andere Weise zusammengestelltes Array trägt nur die Regeln, die seine Collections deklarieren.
  • Die Admin-Basis. Jede Collection erhält außerdem <table>_default_admin_read und <table>_default_admin_write_insert, _update und _delete: permissive Policies, die den Server-Kontext und die Rolle admin jede Zeile lesen und schreiben lassen. Sie stehen neben den Regeln, die die Collection am Ende hat, ihren eigenen oder den geerbten Defaults, und permissive Policies werden per OR verknüpft, sodass sie beide erweitern. Die Users-Collection erhält zudem ein Self-Read und ein restriktives Schreib-Gate, das roles Admins vorbehält.
  • Mandantenfähigkeit. Eine mandantengebundene Collection erhält eine restriktive Regel, die jede Operation auf den Mandanten des Aufrufers beschränkt; der Server-Kontext besteht sie.

disableDefaultPolicies: true an einer Collection entfernt die permissive Basis und behält die restriktiven Gates. Es gilt pro Collection: defaultSecurityRules trägt kein solches Flag, sodass eine Collection, die die Defaults erbt, die Basis behält, sofern sie das Flag nicht selbst setzt. Das generierte DDL markiert jede injizierte Policy, und rebase db push stellt eine von Hand gelöschte wieder her.

Das einfachste Muster — Benutzer können nur auf Zeilen zugreifen, die sie selbst besitzen:

securityRules: [
{ operation: "all", ownerField: "user_id" }
]

Dies generiert: USING ((user_id)::text = (SELECT rebase.uid())) — zum SELECT siehe Einmal pro Statement ausgewertet.

Erlaubt jedem (einschließlich nicht authentifizierten Benutzern) das Lesen:

securityRules: [
{ operation: "select", access: "public" }
]

Dies generiert: USING (true)

Erlaubt den Zugriff für jeden angemeldeten Benutzer. Dies ist eine condition und kein access-Shortcut — access hat genau einen Wert, nämlich "public" —, da „angemeldet“ eine Prüfung des Aufrufers darstellt und der Builder der Ort ist, an dem Prüfungen gegen den Aufrufer stattfinden:

import { policy } from "@rebasepro/types";
securityRules: [
{ operation: "select", condition: policy.authenticated() }
]

policy.authenticated() ist auch für anonyme Anmeldungen (sign-in) wahr, bei denen eine echte Benutzerzeile und eine echte Sitzung erzeugt werden. Verwenden Sie policy.registered(), wenn ein Gast sich nicht qualifizieren soll — etwa beim Verfassen einer Bewertung, beim Beitreten zu einer Organisation oder beim Ausgeben von Geld.

Beschränken Sie Operationen auf bestimmte Rollen:

securityRules: [
{ operation: "all", roles: ["admin"] },
{ operation: "select", roles: ["editor", "viewer"] }
]

Um den Zugriff basierend auf der Mitgliedschaft in einer verwandten Collection einzugrenzen — z. B. „nur Zeilen, zu deren Team der Aufrufer gehört“ —, verwenden Sie die strukturierte condition mit policy.existsIn. Dies wird zu einer einzelnen korrelierten EXISTS-Unterabfrage kompiliert (keine Abfragen pro Zeile) und ist die sichere First-Class-Alternative zum manuellen Schreiben des unten gezeigten reinen SQLs.

import { policy } from "@rebasepro/types";
// documents visible only to members of the document's team:
securityRules: [
{
operation: "select",
condition: policy.existsIn({
collection: "team_members", // the join / membership collection
where: policy.and(
// correlate to the row being checked:
policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")),
// …and to the caller:
policy.compare(policy.field("user_id"), "eq", policy.authUid()),
),
}),
},
]

Innerhalb von where bezieht sich policy.field(...) auf eine Spalte der verknüpften Collection (team_members), während sich policy.outerField(...) auf eine Spalte der zu prüfenden Zeile (documents) bezieht. Kombinieren Sie dies mit policy.authUid(), um die Abfrage auf den aktuellen Benutzer einzugrenzen. Da dies von der Datenbank erzwungen wird, betrachtet die Admin-UI dies als serverseitig maßgeblich.

Wird aus @rebasepro/types importiert. Ausdrücke lassen sich zusammensetzen; Operanden bilden die Blätter.

Ausdruck Kompiliert zu
policy.true() / policy.false() true / false
policy.and(…) / policy.or(…) Konjunktion / Disjunktion
policy.not(e) Negation
policy.compare(left, op, right) Ein Vergleich zwischen zwei Operanden
policy.rolesOverlap(roles) Der Aufrufer hat mindestens eine dieser App-Rollen
policy.rolesContain(roles) Der Aufrufer hat alle dieser App-Rollen
policy.authenticated() Angemeldet — rebase.uid() ist gesetzt und ist kein anonymer Sentinel-Wert. IS NOT NULL allein wäre eine Tautologie, da ein anonymer Request einen Sentinel-Wert setzt, anstatt ihn ungesetzt zu lassen
policy.registered() Mit einem Konto angemeldet — authenticated() und kein Gast. Siehe unten
policy.serverContext() rebase.uid() IS NULL — siehe den Warnhinweis unten
policy.existsIn({ collection, where }) Eine korrelierte EXISTS-Unterabfrage
policy.raw(sql) Ein Escape-Hatch, wird unverändert eingefügt
Operand Bedeutung
policy.field(name) Eine Spalte der zu prüfenden Collection — oder, innerhalb von existsIn, der gejointen Collection
policy.outerField(name) Innerhalb von existsIn, eine Spalte der äußeren Zeile
policy.literal(value) Ein String, eine Zahl, ein Boolean oder null
policy.authUid() rebase.uid()
policy.authRoles() rebase.roles()

Jeder Teil einer Regel, der die Zeile nicht liest — die ID des Aufrufers, eine Rollenprüfung, ein Claim, serverContext() — wird innerhalb einer skalaren Unterabfrage kompiliert. Eine Besitzerregel wird zu (user_id)::text = (SELECT rebase.uid()), und der standardmäßige Admin-Read wird zu (SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])).

Postgres berechnet eine solche Unterabfrage einmal pro Statement (ein initPlan) statt einmal pro Zeile und plant so, als ließe die Prüfung die Hälfte der Tabelle durch statt 1,5%. Diese Schätzung hält eine tiefe Seite einer großen Tabelle auf ihrem Primärschlüssel: Ohne sie waren es bei Seite 401 von einer Million Zeilen ein sequenzieller Scan und eine Sortierung mit 640 MB. Wer was lesen oder schreiben darf, bleibt unverändert.

Rohes using / withCheck-SQL erhält dieselbe Behandlung, wenn Rebase seine Struktur lesen kann (owner_id = rebase.uid() gehört dazu). Alles andere wird unverändert eingefügt, umschließen Sie die Aufrufe also selbst: (SELECT rebase.uid()).

Zwei unterschiedliche Dinge werden als anonym bezeichnet, und es lohnt sich, genau zu unterscheiden, was eine Regel meint.

Ein nicht authentifizierter Request besitzt keinerlei Session. Ihm wird eine Sentinel-ID zugewiesen, sodass rebase.uid() auf dem Benutzerpfad niemals NULL ist, und policy.authenticated() schließt ihn aus — genau das sorgt dafür, dass es „angemeldet“ bedeutet und nicht „jeder“.

Ein Gast ist das andere: eine Session ohne eine reale Person dahinter. POST /auth/anonymous generiert eine echte Benutzerzeile mit einer echten UID, sodass ein Gast jeden Test besteht, der die ID prüft. Das ist der Sinn des Features — ein Warenkorb vor dem Checkout, ein Entwurf vor der Registrierung — und es bedeutet, dass authenticated() für jeden wahr ist, der auf Als Gast fortfahren geklickt hat, was weder E-Mail noch Passwort noch die Zustimmung zu irgendetwas erfordert.

policy.registered() ist authenticated() plus „kein Gast“. Verwenden Sie es überall dort, wo es in einer Regel um eine Person geht, die für etwas zur Verantwortung gezogen werden könnte: das Verfassen einer Bewertung, der Beitritt zu einer Organisation, das Ausgeben von Geld. Greifen Sie zu authenticated(), wenn Gäste ausdrücklich willkommen sind.

// Anyone with a session, guests included — a draft cart.
{ operation: "insert", check: policy.authenticated() }
// Someone with an account.
{ operation: "insert", check: policy.registered() }

Unter der Haube reist das Gast-Flag mit der Session mit — es befindet sich im Access Token und gelangt als rebase.is_anonymous() in die Datenbank —, sodass eine Richtlinie diese Frage ohne Lookup stellen kann. Eine Datenbank, die von einem Server bedient wird, der zu alt ist, um dies zu setzen, interpretiert jede Session als Konto; genau dieses Verhalten hatte das betreffende Deployment bereits zuvor.

Für komplexe Logik verwenden Sie using und withCheck:

securityRules: [
{
operation: "select",
using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())"
}
]
  • using — Filtert, welche bestehenden Zeilen sichtbar sind (gilt für SELECT, UPDATE, DELETE)
  • withCheck — Validiert neue Zeilenwerte (gilt für INSERT, UPDATE)

Spaltenreferenzen verwenden die Syntax {column_name}, die zur vollständig tabellenqualifizierten Spalte aufgelöst wird.

Kombinieren Sie praktische Shortcuts mit reinem SQL:

securityRules: [
// Admins can do anything
{ operation: "all", roles: ["admin"], using: "true" },
// Regular users can only see their own rows
{ operation: "select", ownerField: "user_id" },
// Users can insert, but only for themselves
{ operation: "insert", withCheck: "{user_id} = rebase.uid()" },
// Locked rows cannot be updated
{ operation: "update", mode: "restrictive", using: "{is_locked} = false" }
]

PostgreSQL bietet zwei Richtlinien-Modi:

  • Permissive (Standard) — Mehrere permissive Richtlinien werden mit ODER (OR) verknüpft. Wenn eine davon zutrifft, wird der Zugriff gewährt.
  • Restrictive — Restriktive Richtlinien werden mit UND (AND) verknüpft. Alle müssen zutreffen.
securityRules: [
// Permissive: owners can access their rows
{ operation: "all", ownerField: "user_id" },
// Restrictive: but locked rows cannot be updated
{ operation: "update", mode: "restrictive", using: "{is_locked} = false", withCheck: "{is_locked} = false" }
]
Operation SQL-Äquivalent Beschreibung
"select" SELECT Zeilen lesen
"insert" INSERT Neue Zeilen erstellen
"update" UPDATE Bestehende Zeilen ändern
"delete" DELETE Zeilen entfernen
"all" Alle oben genannten Kurzform für alle Operationen

Sie können auch operations (Plural) verwenden, um eine Regel auf mehrere Operationen anzuwenden:

{ operations: ["insert", "update", "delete"], ownerField: "authorId" }

SecurityRule ist eine Union, kein einzelnes offenes Objekt: Eine Regel wählt genau eine Möglichkeit, ihr Prädikat auszudrücken; die anderen sind mit never typisiert, sodass ihre Vermischung ein Compilerfehler ist, anstatt einer Richtlinie, die die Hälfte dessen, was Sie geschrieben haben, stillschweigend ignoriert.

// Shared by every variant
interface SecurityRuleBase {
name?: string; // Policy name. Omit it and one is derived
operation?: SecurityOperation; // "select" | "insert" | "update" | "delete" | "all"
operations?: SecurityOperation[]; // …or several at once
mode?: "permissive" | "restrictive"; // Default: "permissive"
roles?: string[]; // App roles, via rebase.roles()
pgRoles?: string[]; // Native Postgres roles — the CREATE POLICY `TO` clause.
// NOT the same as `roles`. Default: ["public"]
}
// …plus exactly one of:
{ ownerField: string } // <column> = rebase.uid()
{ access: "public" } // the one shortcut — "no row filter"
{ condition: PolicyExpression; // the structured builder — `policy.*`
check?: PolicyExpression } // defaults to `condition`, as Postgres does
{ using?: string; withCheck?: string } // raw SQL

roles und pgRoles werden häufig verwechselt. roles ist eine Anwendungsrolle, die innerhalb der USING- / WITH CHECK-Klausel über rebase.roles() erzwungen wird. pgRoles ist eine Datenbankrolle und steuert, an welche Verbindungen die Richtlinie überhaupt angehängt wird. Fast jedes Projekt benötigt roles.

securityRules: [
// Anyone can read published posts
{ operation: "select", using: "{status} = 'published'" },
// Authors can see their own drafts
{ operation: "select", ownerField: "authorId" },
// Authors can create and edit their own posts
{ operations: ["insert", "update"], ownerField: "authorId" },
// Only admins can delete
{ operation: "delete", roles: ["admin"] }
]
securityRules: [
{
operation: "all",
using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())"
}
]

Eine häufige Anforderung besteht darin, nicht authentifizierten Benutzern das Übermitteln von Daten zu erlauben — Kontaktformulare, Newsletter-Anmeldungen, öffentliche Bewerbungen. Rebase bietet hierfür ein sauberes Muster.

import { defineCollection } from "@rebasepro/cms-types";
const contactMessagesCollection = defineCollection({
slug: "contact_messages",
name: "Contact Messages",
table: "contact_messages",
securityRules: [
// Anyone can submit a contact message
{
operation: "insert",
// A raw rule carries `using` (which rows are visible) and `withCheck`
// (what a write must satisfy); an insert only exercises the latter.
using: "true",
withCheck: "true"
},
// Only admins can read, update, or delete messages
{ operations: ["select", "update", "delete"], roles: ["admin"] }
],
properties: {
email: { name: "Email", type: "string" }
}
});

Der access: "public"-Shortcut generiert eine Richtlinie, die die Operation ohne erforderliche Authentifizierung erlaubt.

import { defineCollection } from "@rebasepro/cms-types";
const leadSignupsCollection = defineCollection({
slug: "lead_magnet_signups",
name: "Lead Magnet Signups",
table: "lead_magnet_signups",
securityRules: [
// Allow anonymous inserts
{ operation: "insert", using: "true", withCheck: "true" },
// Admins can view all signups
{ operation: "select", roles: ["admin"] }
],
properties: {
email: { name: "Email", type: "string" }
}
});

Wenn eine Anfrage ohne JWT-Token eintrifft, setzt das Rebase-Backend die PostgreSQL-Sitzungsvariablen auf:

Variable Wert
app.user_id 'anonymous'
app.user_roles '' (leer)

Das bedeutet:

  • rebase.uid() gibt 'anonymous' zurück
  • rebase.roles() gibt einen leeren String zurück
  • access: "public"-Richtlinien sind erfolgreich, da sie USING (true) / WITH CHECK (true) erzeugen
  • policy.authenticated()-Bedingungen schlagen fehl, da sie nach einer echten Benutzer-ID prüfen
  • ownerField-Richtlinien schlagen fehl, da keine Zeile user_id = 'anonymous' haben wird (sofern nicht explizit festgelegt)

Wenn Sie eine granularere Kontrolle benötigen, verwenden Sie reines SQL:

securityRules: [
{
operation: "insert",
withCheck: "rebase.uid() = 'anonymous' OR rebase.uid() IS NOT NULL"
}
]

Sicherheitsregeln beantworten eine Frage: Welche Zeilen erreicht dieser Aufrufer. Sie werden von Postgres selbst bei jeder Anweisung durchgesetzt, unabhängig von der Route — weshalb sie das Autorisierungsmodell darstellen und alles darüber reine Bequemlichkeit ist.

Sie sagen nichts über die Spalten einer Zeile aus, die ein Aufrufer tatsächlich erreicht. Eine Richtlinie, die es einem Mitarbeiter erlaubt, die Zeilen seines Teams zu lesen, erlaubt es ihm, jedes Feld dieser Zeilen zu lesen, einschließlich des Gehalts. Genau dafür gibt es das Eigenschafts-basierte access:

salary: {
type: "number",
// Everyone the rules above let read the row; only HR gets this column.
access: { read: ["hr"], write: [] }
}

Beide ergänzen sich und widersprechen sich nie: Eine Feldregel kann den Zeilenzugriff nicht erweitern, und eine Zeile, die Sie nicht lesen können, besitzt keine Felder, über die man sprechen könnte. Rollen sind dieselben Rollen — rebase.roles() innerhalb einer Richtlinie, user.roles im Request —, sodass rolesOverlap(['hr']) in einer Regel und access: { read: ["hr"] } auf einer Eigenschaft dasselbe hr bedeuten. Feldregeln werden vom Server und nicht von Postgres angewendet, sodass sie die API-Oberfläche abdecken; eine Abfrage, die über rebase.sql() ausgeführt wird, sieht jede Spalte, genau so, wie sie RLS umgeht.