Skip to content

Postgres Row-Level Security für Multi-Tenant-Anwendungen

Wie man Tenant-Isolation auf Datenbankebene mit Postgres RLS durchsetzt, statt sich auf Anwendungscode zu verlassen, das an eine WHERE-Klausel denkt.

5 Min. Lesezeit
Diagramm, das zeigt, wie Postgres-Row-Level-Security-Policies Tenant-Daten auf Datenbankebene filtern

Jede Multi-Tenant-SaaS-Anwendung, an der ich gearbeitet habe, erlebt irgendwann denselben Vorfall: Ein Entwickler vergisst eine WHERE tenant_id = $1-Klausel in einer neuen Query, und plötzlich kann Tenant A die Rechnungen von Tenant B sehen. Code Reviews fangen die meisten dieser Fälle ab, aber nicht alle — und „die meisten" reicht nicht, wenn es sich um Kunden-PII oder Finanzdaten handelt.

Die Lösung ist nicht mehr Disziplin. Es geht darum, die Tenant-Isolation aus dem Anwendungscode heraus- und in die Datenbank selbst hineinzuverlagern. Mit Postgres Row-Level Security (RLS) kannst du Policies definieren, die Zeilen automatisch filtern, sodass selbst eine Query mit fehlender WHERE-Klausel keine Daten über Tenants hinweg leaken kann. Das ist kein Allheilmittel, aber für dieses spezielle Problem kommt es dem ziemlich nahe.

Warum Isolation auf Anwendungsebene irgendwann versagt

Das typische Muster sieht auf dem Papier sicher aus: Jede Repository-Methode nimmt eine tenantId entgegen und hängt sie an die Query an.

tstypescript
// ❌ Isolation depends on every developer remembering this pattern, every time
async function getInvoices(tenantId: string) {
  return db.query(
    "SELECT * FROM invoices WHERE tenant_id = $1",
    [tenantId]
  );
}
 
// One missed join, one raw query for a "quick report",
// one ORM eager-load without a scope — and it's gone.
async function getInvoicesWithLineItems(tenantId: string) {
  return db.query(
    `SELECT i.*, li.* FROM invoices i
     JOIN line_items li ON li.invoice_id = i.id
     WHERE i.tenant_id = $1` // easy to forget on li.tenant_id too
  );
}

Das funktioniert, bis es nicht mehr funktioniert. Admin-Skripte, Background-Jobs, Datenmigrationen und Drittanbieter-Integrationen umgehen früher oder später alle deine sorgfältig gescopte Repository-Schicht. Die Datenbank ist der einzige Ort, der jede Query sieht, unabhängig von ihrem Ursprung — das macht sie zur richtigen Durchsetzungsgrenze.

RLS-Policies einrichten

RLS ist standardmäßig deaktiviert. Du aktivierst es pro Tabelle und definierst dann Policies, die festlegen, welche Zeilen für eine gegebene Session sichtbar sind.

sqlsql
-- Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
 
-- Force RLS even for the table owner (critical — see below)
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
 
-- Policy: only rows matching the current tenant context are visible
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
 
-- Same policy for writes
CREATE POLICY tenant_isolation_insert ON invoices
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

current_setting('app.current_tenant_id') liest eine sitzungslokale Variable, die du am Anfang jedes Requests setzt. Das ist das Stück, das den Auth-Kontext deiner Anwendung mit der Zeilenfilterung von Postgres verbindet.

!

Ohne FORCE ROW LEVEL SECURITY umgehen Tabelleneigentümer RLS standardmäßig vollständig. Wenn sich deine Anwendung mit einer Rolle verbindet, der die Tabellen gehören (üblich bei ORMs, die auch Migrationen ausführen), bewirken deine Policies gar nichts, bis du sie erzwingst.

Tenant-Kontext in deinen Connection Pool einbinden

Das Tückische am Connection Pooling ist, dass SET sitzungsgebunden ist, gepoolte Verbindungen aber über Requests hinweg wiederverwendet werden. Du musst die Tenant-Variable am Anfang jeder Transaktion setzen, nicht einmal pro Verbindung.

tstypescript
// ✅ Set tenant context per-transaction, not per-connection
async function withTenantContext<T>(
  pool: Pool,
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // set_config with local=true scopes the setting to this transaction
    await client.query(
      "SELECT set_config('app.current_tenant_id', $1, true)",
      [tenantId]
    );
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
 
// Usage in a request handler
app.get("/invoices", async (req, res) => {
  const invoices = await withTenantContext(pool, req.tenantId, (client) =>
    client.query("SELECT * FROM invoices") // no WHERE needed — RLS handles it
  );
  res.json(invoices.rows);
});

Das dritte Argument true bei set_config macht die Einstellung transaktionslokal — sie wird bei Commit oder Rollback automatisch zurückgesetzt, sodass keine Gefahr besteht, dass der Tenant-Kontext an den nächsten Request durchsickert, der sich diese gepoolte Verbindung schnappt.

Umgang mit Rollen, die legitim tenantübergreifenden Zugriff brauchen

Nicht jede Query sollte tenant-gescoped sein. Background-Jobs, die Metriken über alle Tenants hinweg aggregieren, oder Admin-Dashboards für dein Support-Team brauchen einen anderen Weg.

sqlsql
-- A dedicated role that bypasses tenant policies for legitimate cross-tenant work
CREATE ROLE reporting_service BYPASSRLS;
GRANT SELECT ON invoices TO reporting_service;
 
-- Or, keep RLS on but add an explicit policy for admin access
CREATE POLICY admin_full_access ON invoices
  USING (current_setting('app.is_admin', true) = 'true');

Bevorzuge, wo möglich, die explizite Policy gegenüber BYPASSRLS — sie ist über pg_policies auditierbar und erfordert keine separate, quasi-superuser-artige Rolle. Hebe dir BYPASSRLS für Infrastruktur-Jobs auf, die nie mit tenant-gescoped Anwendungslogik in Berührung kommen.

Policies testen wie Business-Logik

RLS-Policies sind Code. Ungetesteter Code hat Bugs. Schreib Tests, die tatsächlich prüfen, ob die Isolation hält — nicht nur, dass Queries Ergebnisse liefern.

sqlsql
-- Test: tenant B must never see tenant A's rows
BEGIN;
SELECT set_config('app.current_tenant_id', 'tenant-a-uuid', true);
INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-a-uuid', 100);
 
SELECT set_config('app.current_tenant_id', 'tenant-b-uuid', true);
-- This should return zero rows, not an error and not tenant A's data
SELECT count(*) FROM invoices WHERE amount = 100;
ROLLBACK;
tstypescript
// Integration test hitting the real database, not a mock
describe("tenant isolation", () => {
  it("prevents cross-tenant reads even without a WHERE clause", async () => {
    await withTenantContext(pool, TENANT_A, (client) =>
      client.query("INSERT INTO invoices (tenant_id, amount) VALUES ($1, $2)", [
        TENANT_A,
        500,
      ])
    );
 
    const result = await withTenantContext(pool, TENANT_B, (client) =>
      client.query("SELECT * FROM invoices")
    );
 
    expect(result.rows).toHaveLength(0);
  });
});

Führe diese Suite in CI gegen eine echte Postgres-Instanz aus, nicht gegen SQLite oder ein In-Memory-Shim — RLS-Verhalten ist Postgres-spezifisch und existiert in den meisten schlanken Test-Datenbanken nicht.

Performance-Überlegungen

RLS-Policies sind im Grunde WHERE-Klauseln, die der Planner an jede Query anhängt, also profitieren sie von denselben Indexierungsregeln. Stell sicher, dass tenant_id indexiert ist, idealerweise als führende Spalte in zusammengesetzten Indizes für tenant-gescoped Tabellen.

sqlsql
-- ✅ tenant_id first means the planner can filter before scanning
CREATE INDEX idx_invoices_tenant_created ON invoices (tenant_id, created_at DESC);

Prüfe außerdem die Query-Pläne nach dem Aktivieren von RLS — in seltenen Fällen können komplexe Policies mit Subqueries die Nutzung von Indizes verhindern. Führe nach der Migration EXPLAIN ANALYZE auf deinen heißesten Queries aus und vergleiche mit der Baseline vor RLS.

Die wichtigsten Erkenntnisse

  1. Tenant-Filterung auf Anwendungsebene versagt irgendwann — Background-Jobs, Admin-Tools und vergessene WHERE-Klauseln umgehen sie alle. RLS erzwingt Isolation auf der einen Ebene, die jede Query durchläuft.
  2. Kombiniere ENABLE ROW LEVEL SECURITY immer mit FORCE ROW LEVEL SECURITY, sonst umgehen Tabelleneigentümer deine Policies stillschweigend.
  3. Setze den Tenant-Kontext mit set_config(..., true) innerhalb einer Transaktion, nicht als rohes SET auf einer gepoolten Verbindung — sonst leakt er über Requests hinweg.
  4. Nutze explizite, auditierbare Policies für tenantübergreifenden Zugriff (Admin-Rollen, Reporting), statt standardmäßig zu BYPASSRLS zu greifen.
  5. Teste RLS-Policies in CI gegen eine echte Postgres-Instanz — Isolations-Bugs sind genau die Art von Problem, die sich sonst erst in Produktion zeigt.
  6. Indexiere tenant_id als führende Spalte und überprüfe die Query-Pläne nach dem Rollout; RLS-Policies sind echte Prädikate, die den Planner beeinflussen.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX