Multi-tenant architectuurdiagram met Main Database en FDW-verbindingen naar geisoleerde tenant-databases, beveiligd met Row Level Security
Back to blog
FluxbasePostgreSQLArchitectuurOpen Source

Multi-Tenancy met postgres_fdw: Hoe Fluxbase Tenantdata Isoleert

8 min read

Hoe ik multi-tenant isolatie aan Fluxbase heb toegevoegd met PostgreSQL's Foreign Data Wrapper, Row Level Security en een hybride database architectuur, zonder backward compatibility te breken.

Waarom ik dit wilde bouwen

Fluxbase begon als een single-tenant backend, en dat werkte prima voor mijn eigen projecten. Maar ik bleef stuiten op een gemis: self-hosted Supabase ondersteunt geen multi-tenancy, en dat wilde ik Fluxbase wel kunnen bieden. Teams en organisaties die één instance delen met sterke data-isolatie leek me te waardevol om te laten liggen.

De belangrijkste eis was isolatie op PostgreSQL-niveau, niet alleen filtering in de applicatielaag die omzeild kan worden door een bug. En ik wilde de bestaande single-tenant ervaring waar mensen al mee in productie zaten niet breken.

De oplossing bleek een hybride database architectuur [6] te zijn, gebouwd op PostgreSQL’s postgres_fdw [1] "But postgres_fdw provides more transparent and standards-compliant syntax for accessing remote tables, and can give better performance in many cases." View source ↗ en Row Level Security [3] "When row security is enabled on a table (with ALTER TABLE ... ENABLE ROW LEVEL SECURITY), all normal access to the table for selecting rows or modifying rows must be allowed by a row security policy." View source ↗ .

Architectuur in het kort

Fluxbase gebruikt een hybride model. Een default tenant deelt de hoofd database: geen aparte database, geen FDW, geen wijzigingen. Named tenants krijgen elk hun eigen PostgreSQL database, maar wel op dezelfde PostgreSQL instance. Dat is belangrijk, want de foreign data wrapper verbindt databases binnen één cluster, niet over aparte servers. Geen netwerk-hop, geen extra authenticatie, en transacties blijven lichtgewicht.

Elke tenant database heeft een eigen public schema voor gebruikersdata, volledig geisoleerd. De tabellen die Fluxbase zelf beheert (authenticatie, storage, edge functions en zo verder) bestaan eenmalig in de hoofd database. Tenant databases benaderen ze via foreign tables, waarbij Row Level Security afdwingt dat elke tenant alleen eigen data ziet.

graph TB classDef infra fill:#eef2ff,stroke:#4466ee,stroke-width:2px,color:#1e293b classDef user fill:#f0fdf4,stroke:#059669,stroke-width:2px,color:#1e293b classDef foreign fill:#fefce8,stroke:#d97706,stroke-width:2px,color:#1e293b classDef lock fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#991b1b subgraph PG[" PostgreSQL Instance"] subgraph MAIN[" Main Database"] AUTH["auth.* 🔐"]:::infra STORE["storage.* 🔒"]:::infra FUNC["functions.* ⚡"]:::infra MORE_M["+ more schemas"]:::infra end subgraph ACME[" Tenant DB: acme"] PUB1["public.* 📊
Geisoleerde gebruikersdata"]:::user FDW1["Foreign tables 🔗
auth.* · storage.* · functions.* · …"]:::foreign end subgraph GLOBEX[" Tenant DB: globex"] PUB2["public.* 📊
Geisoleerde gebruikersdata"]:::user FDW2["Foreign tables 🔗
auth.* · storage.* · functions.* · …"]:::foreign end LOCK["🔒
NOBYPASSRLS"]:::lock end FDW1 -->|"postgres_fdw"| LOCK FDW2 -->|"postgres_fdw"| LOCK LOCK --> AUTH LOCK --> STORE LOCK --> FUNC style PG fill:#f8f9ff,stroke:#d4d8f0,stroke-width:2px,stroke-dasharray:8 4 style MAIN fill:#ffffff,stroke:#4466ee,stroke-width:2px style ACME fill:#ffffff,stroke:#059669,stroke-width:2px style GLOBEX fill:#ffffff,stroke:#7c3aed,stroke-width:2px style MORE_M fill:none,stroke:none,color:#94a3b8 style LOCK fill:#fef2f2,stroke:#ef4444,stroke-width:2px
Tabellen beschermd door Row Level Security. Elke tenant verbindt via een eigen FDW role met NOBYPASSRLS. De role kan RLS policies niet omzeilen, waardoor tenant-isolatie op database niveau wordt afgedwongen.

Backward Compatibility

De default tenant is de sleutel tot backward compatibility. Die gebruikt de hoofd database pool zonder extra bewegende onderdelen. Bestaande single-tenant deployments blijven identiek werken. Geen FDW, geen extra databases, geen configuratiewijzigingen.

Multi-tenancy is opt-in. Je betaalt de overhead pas als je een named tenant aanmaakt. Voor deployments die het nooit nodig hebben verandert er niets.

Hoe een request wordt gerouteerd

Als een request een tenant identificeert, handelt een connection pool router de routering transparant af [7] "Connection pool router die queries routeert naar de juiste database op basis van tenant context." View source ↗ :

graph LR classDef client fill:#f8f9ff,stroke:#4466ee,stroke-width:2px,color:#1e293b classDef router fill:#eef2ff,stroke:#4466ee,stroke-width:2px,color:#1e293b classDef pool fill:#ffffff,stroke:#4466ee,stroke-width:2px,color:#1e293b classDef local fill:#f0fdf4,stroke:#059669,stroke-width:2px,color:#1e293b classDef fdw fill:#fefce8,stroke:#d97706,stroke-width:2px,color:#1e293b classDef rls fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#1e293b classDef result fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#1e293b CLIENT["Client Request"]:::client -->|"tenant: acme"| ROUTER["Connection Router"]:::router ROUTER -->|"default tenant"| MAIN["Main DB pool"]:::pool ROUTER -->|"named tenant"| TENANT["Tenant DB pool"]:::pool TENANT -->|"public.* queries"| LOCAL["Local tables
Geisoleerde gebruikersdata"]:::local TENANT -->|"infrastructure queries"| FDW["postgres_fdw"]:::fdw FDW -->|"via NOBYPASSRLS role"| RLS["RLS filters
by tenant_id"]:::rls RLS --> RESULT["Scoped results"]:::result
Voor named tenants worden queries naar infrastructure schemas via FDW gerouteerd naar de hoofd database, waar RLS policies de resultaten filteren voordat ze terugkomen.

Voor de default tenant gaat alles direct naar de hoofd database. FDW is daar helemaal niet bij betrokken.

postgres_fdw als lijm

Elke named tenant database gebruikt de postgres_fdw extensie om shared infrastructure tabellen uit de hoofd database te benaderen. Vanuit het perspectief van de applicatie zien deze foreign tables eruit en gedragen ze zich als lokale tabellen [1] "Now you need only SELECT from a foreign table to access the data stored in its underlying remote table. You can also modify the remote table using INSERT, UPDATE, DELETE, COPY, or TRUNCATE." View source ↗ .

De setup gebeurt automatisch als een tenant wordt aangemaakt [7] "Tenant manager handelt database aanmaak, FDW setup, role provisioning en schema imports af in een enkele transactie." View source ↗ :

  1. Maak de tenant database aan: een verse PostgreSQL database binnen dezelfde instance
  2. Maak een per-tenant FDW role aan op de hoofd database. Deze role wordt aangemaakt met NOBYPASSRLS [4] "These clauses determine whether a role bypasses every row-level security (RLS) policy. NOBYPASSRLS is the default." View source ↗ , wat betekent dat Row Level Security niet omzeild kan worden
  3. Import foreign schemas [2] "IMPORT FOREIGN SCHEMA creates foreign tables that represent tables existing on a foreign server." View source ↗ : de infrastructure schemas worden geimporteerd als foreign tables die naar de hoofd database wijzen
  4. Configureer de user mapping. De applicatie verbindt via de per-tenant FDW role, die een standaard tenant identifier heeft ingesteld op role niveau [5] "ALTER ROLE kan configuratie defaults instellen die van toepassing zijn zodra de role een sessie start, inclusief custom settings zoals app.current_tenant_id." View source ↗

Het resultaat: infrastructure tabellen bestaan eenmalig in de hoofd database, maar elke tenant database ziet ze als lokale tabellen. Het NOBYPASSRLS attribuut van de FDW role zorgt dat RLS policies ook via foreign table toegang worden afgedwongen.

WHERE clausules worden pushed

Een logische zorg bij FDW is performance. Gelukkig pushed postgres_fdw WHERE clausules door naar de externe server [9] "postgres_fdw attempts to optimize remote queries to reduce the amount of data transferred from foreign servers. This is done by sending query WHERE clauses to the remote server for execution." View source ↗ . Als een tenant gebruikers opvraagt met een filter, dan gebeurt de filtering op de hoofd database in plaats van alle rijen ophalen en lokaal filteren.

Transactieconsistentie

postgres_fdw beheert externe transacties transparant [8] "The remote transaction is committed or aborted when the local transaction commits or aborts." View source ↗ . Een transactie die zowel de eigen tabellen van de tenant als foreign tables raakt, wordt atomair gecommit of teruggedraaid. Omdat alles op dezelfde PostgreSQL instance draait blijft dit snel, want er is geen coordinatie tussen servers nodig.

Row Level Security

FDW geeft toegang tot shared tabellen. RLS geeft isolatie.

Op elke infrastructure tabel staat Row Level Security ingeschakeld met policies die filteren op een tenant identifier [3] "When row security is enabled on a table (with ALTER TABLE ... ENABLE ROW LEVEL SECURITY), all normal access to the table for selecting rows or modifying rows must be allowed by a row security policy." View source ↗ . De per-tenant FDW role heeft deze identifier als standaard ingesteld, dus queries via FDW worden automatisch gescooped. Geen applicatiecode nodig.

Het defense-in-depth model:

  • Superusers en BYPASSRLS roles slaan RLS over [3] "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table." View source ↗ , gereserveerd voor interne operaties zoals migraties
  • Service roles respecteren RLS en worden gebruikt voor normale request verwerking
  • FDW roles worden aangemaakt met NOBYPASSRLS [4] "NOBYPASSRLS is de standaard. De role kan geen row-level security policy omzeilen op tabellen die hij benadert." View source ↗ , zodat foreign table queries altijd gefilterd worden

Als een bug in de applicatie de verkeerde tenant context meestuurt, voorkomt RLS nog steeds cross-tenant data toegang. De isolatie wordt op database niveau afgedwongen, niet in applicatiecode.

Voordelen

Deze architectuur heeft een aantal praktische voordelen naast isolatie:

Schema migraties eenmalig uitvoeren. Omdat alle infrastructure tabellen in de hoofd database leven en via FDW worden benaderd, hoef ik ze bij een Fluxbase upgrade maar één keer te migreren. Elke tenant ziet automatisch het bijgewerkte schema via de foreign tables. Geen migraties draaien tegen elke afzonderlijke tenant database voor infrastructure wijzigingen.

Gedeeld infrastructure onderhoud. Eén set auth, storage en function tabellen om te monitoren, back-uppen en optimaliseren. Het operationele oppervlak groeit niet mee met het aantal tenants.

Same-cluster performance. Omdat alle databases op dezelfde PostgreSQL instance draaien hebben FDW queries geen netwerk overhead. De foreign data wrapper is gewoon een manier om database grenzen binnen één cluster over te steken.

Transparant voor applicatiecode. Queries zien er hetzelfde uit, of ze nu een lokale tabel of een foreign table raken. De router regelt de verbinding, RLS regelt de filtering, en de applicatie schrijft gewoon SQL.

Het resultaat

De combinatie van postgres_fdw en Row Level Security geeft Fluxbase sterke tenant isolatie op een enkele PostgreSQL instance:

  • Data-isolatie: de gebruikersdata van elke tenant leeft in een aparte database
  • Gedeelde infrastructure: auth, storage, functions bereikbaar via FDW, gefilterd door RLS
  • Backward compatible: bestaande deployments werken ongewijzigd
  • Enkele migratie: infrastructure schema wijzigingen propageren automatisch via FDW

Dit alles draait op PostgreSQL alleen. Geen Redis, geen message queues, geen extra services. Gewoon PostgreSQL die doet wat het het beste kan.