Sichere Multi-Tenancy in Spring Boot mit PostgreSQL Row Level Security

Wenn du eine SaaS-Anwendung entwickelst, steht eine der ersten architektonischen Entscheidungen vor dir, die täuschend simpel wirkt: Wie stellst du sicher, dass Firma A niemals die Daten von Firma B sehen kann?

Multi-Tenancy ist das architektonische Muster, das diese Frage beantwortet – ein Setup, bei dem eine einzige Anwendungsinstanz mehrere Kunden bedient, sogenannte Tenants, während ihre Daten vollständig voneinander isoliert bleiben.

Es gibt mehrere Wege, das umzusetzen, und welchen Ansatz du wählst, hat langfristige Konsequenzen für dein Sicherheitsmodell, die Komplexität deiner Infrastruktur und die Skalierbarkeit. In diesem Beitrag zeigen wir dir unseren bevorzugten Ansatz: eine geteilte Datenbank mit Row Level Security (RLS), die direkt von PostgreSQL erzwungen wird, verknüpft mit einer Spring Boot-Anwendung, wobei Keycloak die Authentifizierung übernimmt.

Falls du schon Implementierungen gesehen hast, die sich rein auf Filterung auf Anwendungsebene verlassen, um Tenant-Daten zu trennen, wirst du verstehen, warum es einen spürbaren Unterschied macht, diese Durchsetzung auf Datenbankebene zu verlagern.

Multi-Tenancy-Modelle

Es gibt verschiedene Wege, Multi-Tenancy umzusetzen.

1. Eine Datenbank pro Tenant

Jeder Tenant hat seine eigene Datenbank.

Vorteile: Starke Isolation

Nachteile: Schwer skalierbar bei vielen Tenants, Infrastruktur-Management wird komplex

2. Ein Schema pro Tenant

Eine einzige Datenbank mit einem separaten Schema pro Tenant.
Hier findest du einen bestehenden Blog-Beitrag, der diesen Ansatz behandelt: Multitenancy with Spring Boot — N47

Vorteile: Gute Isolation, Einfacheres Management als mit mehreren Datenbanken

Nachteile: Schema-Migrationen müssen pro Tenant ausgeführt werden

3. Geteilte Datenbank, geteiltes Schema

Alle Tenants teilen sich die gleichen Tabellen, und die Daten werden über eine Tenant-Identifier-Spalte getrennt.

Beispiel: tenant_id

Vorteile: Einfaches Skalieren, Einzelne Datenbank-Migration, Einfaches Tenant-Onboarding

Nachteile: Erfordert starke Zugriffskontrolle, um Daten-Leaks zu vermeiden

Ein praktischer und eleganter Weg, dieses Modell sicher zu machen, ist es, sich auf PostgreSQLs Row Level Security (RLS) zu verlassen, um die Datenisolation durchzusetzen. Das ist der Ansatz, den wir verwenden werden.

Multi-Tenancy mit Spring Boot und PostgreSQL (RLS) – Unser Ansatz

Bei N47 haben wir genau dieses Pattern angewendet, als wir SaaS-Produkte entwickelt haben, bei denen Datenisolation eine vertragliche Anforderung ist, nicht nur eine Best Practice. Das Shared-Schema-Modell mit RLS gibt uns die richtige Balance: operative Einfachheit und Infrastruktur-Effizienz, ohne Kompromisse bei der Sicherheitsgrenze zwischen Tenants. Es ist die Architektur, zu der wir greifen, wenn ein Kunde schnell vorankommen muss, ohne bei der Sicherheit Abstriche zu machen. Du kannst dir die Art von Produkten, in die dieses Denken einfliesst, auf unserer Hello Today Projektseite ansehen.

Die Idee ist unkompliziert:

  • Spring Boot setzt die korrekte tenantId für jede Anfrage
  • PostgreSQL erzwingt die Datenisolation über RLS

Dieses Setup bietet starke Tenant-Isolation und hält die Architektur dabei simpel.

In diesem Beispiel verwenden wir:

  • Spring Boot
  • PostgreSQL
  • Row Level Security (RLS)
  • Keycloak für die Authentifizierung

Jede Tabelle enthält eine tenant_id-Spalte, und PostgreSQL stellt sicher, dass Tenants nur auf ihre eigenen Daten zugreifen können.

Architektur-Überblick

Bevor wir in den Code einsteigen, lohnt es sich, den kompletten Request-Flow zu skizzieren, damit jede Komponente im Kontext Sinn ergibt.

Unsere Lösung hat zwei klar getrennte Verantwortlichkeiten. Spring Boot ist dafür zuständig, den Tenant bei jeder eingehenden Anfrage zu identifizieren und diese Identität über den gesamten Request-Lifecycle weiterzugeben. PostgreSQL ist dafür zuständig, die Datenisolation durchzusetzen und sicherzustellen, dass jede Datenbank-Query automatisch nur die Zeilen zurückgibt, die zum aktiven Tenant gehören.

Der Flow sieht für jede Anfrage so aus:

  1. Eine Anfrage kommt an und trägt ein JWT-Token, das von Keycloak ausgestellt wurde.
  2. Der TenantFilter extrahiert den tenantId-Claim aus dem Token und speichert ihn in einem TenantContext – einer Thread-lokalen Variable, auf die überall in der Anwendung für die Dauer dieser Anfrage zugegriffen werden kann.
  3. Wenn eine Datenbankverbindung geöffnet wird, führt Hibernates Connection Provider SET ROLE {tenantId} aus und wechselt damit die aktive PostgreSQL-Rolle zu der Rolle, die zu diesem Tenant gehört.
  4. PostgreSQLs Row Level Security Policy wertet bei jeder Query tenant_id = current_user aus und filtert Zeilen automatisch – keine WHERE-Klausel auf Anwendungsebene nötig.
  5. Sobald die Anfrage abgeschlossen ist, führt die Verbindung RESET ROLE aus, der TenantContext wird geleert, und die Verbindung wird sauber an den Pool zurückgegeben.

Um diesen Flow zu unterstützen, speichern wir die tenantId an zwei Stellen: als User-Attribut innerhalb des Keycloak-JWT-Tokens und als tenant_id-Spalte in jeder Datenbanktabelle. Dieses duale Setup ermöglicht es dem Tenant-Kontext, von der Authentifizierungsschicht bis hinunter zur Durchsetzung auf Datenbankebene zu wandern, ohne dass zusätzliche Filterlogik im Anwendungscode verstreut werden muss.





Keycloak zur Tenant-Identifikation

Wir nutzen Keycloak als Identity Provider.

Wenn wir einen User anlegen, speichern wir die tenantId als User-Attribut.

Beispiel:

private Map<String, List<String>> mapAttributes(UserCreateInput userCreateInput) {
    Map<String, List<String>> attributes = new HashMap<>();
    attributes.put("tenantId",
        List.of(authenticationFacade.getAuthenticatedUser().getTenantId()));
    return attributes;
}

Dieses Attribut ist im JWT-Token enthalten und ermöglicht es unserem Backend zu bestimmen, zu welchem Tenant ein User gehört.

PostgreSQL-Setup (Row Level Security)

1. Tenant-Role anlegen

Jeder Tenant wird als PostgreSQL-Rolle dargestellt.

CREATE ROLE {tenantId};

GRANT {tenantId} TO CURRENT_USER;

GRANT USAGE ON SCHEMA public TO {tenantId};

GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public
TO {tenantId};

ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {tenantId};

Das stellt sicher, dass die Tenant-Rolle Zugriff auf die Tabellen hat.

2. Tabelle mit Tenant-Spalte erstellen

Beispiel-Tabelle:

CREATE TABLE employee
(
    id UUID PRIMARY KEY,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    tenant_id TEXT
);

3. Row Level Security aktivieren

ALTER TABLE employee ENABLE ROW LEVEL SECURITY;

4. Tenant-Policy anlegen

CREATE POLICY check_tenant_owner
ON employee
USING (tenant_id = current_user);

Jetzt stellt PostgreSQL sicher, dass Tenants nur ihre eigenen Zeilen sehen.

5. tenant_id automatisch befüllen

Wir nutzen einen Trigger, um die tenant_id automatisch zu befüllen.

CREATE FUNCTION set_tenant_id()
RETURNS TRIGGER AS $$
BEGIN
    NEW.tenant_id = current_user;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Trigger:

CREATE TRIGGER employee_trigger_set_tenant_id
BEFORE INSERT ON employee
FOR EACH ROW
EXECUTE PROCEDURE set_tenant_id();

Jetzt erhält jede eingefügte Zeile automatisch die korrekte tenant_id.

Spring Boot-Konfiguration

Spring Boot stellt sicher, dass die korrekte Tenant-Rolle bei jeder Anfrage gesetzt wird.

TenantContext

In einer typischen Spring Boot-Anwendung bearbeitet ein Thread eine einzelne Anfrage. Indem wir die tenantId in einem ThreadLocal speichern, hängen wir die Tenant-Information an diesen Thread. Das ermöglicht es jedem Teil der Anwendung, auf den aktuellen Tenant zuzugreifen, ohne ihn explizit durch jeden Methodenaufruf durchzureichen.

public final class TenantContext {

  private static final ThreadLocal<String> tenantId =
      new InheritableThreadLocal<>();

  public static void setTenantId(String tenant) {
    tenantId.set(tenant);
  }

  public static String getTenantId() {
    return tenantId.get();
  }

  public static void clear() {
    tenantId.remove();
  }
}

Tenant-Filter

Dieser Filter ist das Element, das den TenantContext für jede eingehende Anfrage „aktiviert». Er stellt sicher, dass der tenant gesetzt wird, bevor die Anwendungslogik läuft, und genauso wichtig: dass er danach wieder aufgeräumt wird.

OncePerRequestFilter ist eine Spring-Komponente, die garantiert, dass der Filter genau einmal pro HTTP-Anfrage läuft.

public class TenantFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response,
      FilterChain filterChain)
      throws ServletException, IOException {

    String tenantId = HtAuthenticationUtils.getOptionalTenantId();

    TenantContext.setTenantId(tenantId); // <-- hier setzen wir die tenantId, Wert extrahiert aus dem JWT-Token

    filterChain.doFilter(request, response);

    TenantContext.clear(); // <-- Aufräumen nach Abschluss der Anfrage
  }
}

Tenant Identifier Resolver

Hibernate nutzt diese Klasse, um den aktiven Tenant zu bestimmen.

@Component
public class TenantIdentifierResolver
        implements CurrentTenantIdentifierResolver {

  @Override
  public String resolveCurrentTenantIdentifier() {

    return TenantContext.getTenantId() != null
        ? """ + TenantContext.getTenantId() + """
        : "Non existing tenant.";
  }

  @Override
  public boolean validateExistingCurrentSessions() {
    return false;
  }
}

Connection Provider

Der Connection Provider setzt die PostgreSQL-Rolle.

@Override
public Connection getConnection(String tenantIdentifier)
        throws SQLException {

  final Connection connection = dataSource.getConnection();

  try (PreparedStatement preparedStatement =
           connection.prepareStatement(
             "SET ROLE " + tenantIdentifier)) {

      preparedStatement.execute();
  }

  return connection;
}

Nach Abschluss der Anfrage:

@Override
public void releaseConnection(String tenantIdentifier,
        Connection connection) throws SQLException {

    try (Statement sql = connection.createStatement()) {
        sql.execute("RESET ROLE");
    }

    connection.close();
}

Vorteile dieses Ansatzes

  • Einfache Infrastruktur: Eine Datenbank, ein Schema, keine Deployments pro Tenant
  • Starke Sicherheit: PostgreSQL erzwingt die Datenisolation
  • Einfaches Tenant-Onboarding: Neue Tenants können dynamisch erstellt werden, indem du eine neue Rolle anlegst
  • Zentrale Migrationen: Datenbank-Änderungen gelten für alle Tenants

Fazit

Wenn du Spring Boots Request-Lifecycle-Management mit PostgreSQL Row Level Security kombinierst, erhältst du eine Multi-Tenancy-Lösung, die sowohl einfach zu betreiben als auch wirklich schwer falsch zu konfigurieren ist. Die Isolationsgarantie liegt auf Datenbankebene, was bedeutet, dass ein Bug in deiner Anwendungslogik nicht versehentlich die Daten eines Tenants an einen anderen durchsickern lassen kann. PostgreSQL fängt es ab.

Um zusammenzufassen, was diese Architektur liefert: eine Datenbank, ein Schema, zentrale Migrationen, dynamisches Tenant-Onboarding und eine Sicherheitsgrenze, die PostgreSQL bei jeder einzelnen Query durchsetzt, unabhängig davon, was die Anwendungsschicht macht.

Ein paar Dinge, die du im Hinterkopf behalten solltest, wenn du das in Production umsetzt: Validiere und bereinige die tenantId, bevor du sie in irgendeinen SQL-String konkatenierst, überlege dir bewusst, ob InheritableThreadLocal die richtige Wahl ist, wenn du async Processing nutzt, und füge deinen tenant_id-Spalten einen NOT NULL Constraint hinzu, damit das Schema erzwingt, was die Policy schützt.

Falls du ein SaaS-Produkt entwickelst und die Architektur von Anfang an richtig aufsetzen möchtest – oder falls du ein Multi-Tenant-System geerbt hast, das von Filtern auf Anwendungsebene zusammengehalten wird und du langsam Sorgen bekommst – würden wir gerne mit dir sprechen. Bei N47 ist das genau die Art von Problem, bei der wir Kunden helfen. Melde dich bei uns oder schau dir mal unsere Bereiche Software Engineering und Architecture an, um zu sehen, wie wir arbeiten.

Und falls es dir Spass macht, solche Probleme zu lösen, suchen wir neue Leute.

Leave a Reply


The reCAPTCHA verification period has expired. Please reload the page.