Now-Next

SaaS-architectuur

Multi-tenant vanaf dag één, of het wordt nooit meer goed

Drie beslissingen in een SaaS-product zijn later bijna niet meer recht te trekken. Klantscheiding is de eerste, en de duurste om over te slaan.

Chris van Eijk, Now-Next · · 7 min lezen

In een SaaS-product zijn de meeste beslissingen omkeerbaar. Een scherm bouw je opnieuw, een API-vorm verander je met een versie erbij, een framework vervang je als het echt moet. Drie dingen zijn dat niet, en de eerste is hoe je klanten uit elkaar houdt.

Sla je die over, dan is het niet zo dat je hem later “nog even doet”. Je bouwt dan een migratie waarbij elke query, elke achtergrondtaak, elk rapport en elke export opnieuw langs de meetlat moet — terwijl er productiedata in staat van klanten die van elkaar niet mogen weten dat ze bestaan.

Wat is multi-tenancy, en waarom moet je het vanaf dag één regelen?

Multi-tenancy is de manier waarop één installatie van een SaaS-product de gegevens van meerdere klanten uit elkaar houdt. Het houdt alleen stand met twee sloten tegelijk: een tenantfilter in de applicatie én row-level security in PostgreSQL, zodat een rauwe query, een achtergrondtaak of een import zonder klantcontext gewoon nul rijen terugkrijgt. Vooraf kost die dubbele scheiding ongeveer twee dagen werk; achteraf inbouwen kost weken, plus de onzekerheid of je alles hebt gehad — en die onzekerheid gaat nooit meer weg.

De fout die iedereen één keer maakt

De gewone aanpak is een kolom tenant_id op elke tabel en een filter in de applicatie. Meestal via een globale scope in de ORM, zodat je hem niet elke keer hoeft te typen.

Dat werkt, tot het niet werkt. De gaten zitten altijd op dezelfde plekken:

  • Een rauwe query. Iemand schrijft één keer SQL met de hand voor een rapport dat te traag was. De scope van de ORM zit daar niet omheen.
  • Een achtergrondtaak. Die draait zonder ingelogde gebruiker, dus zonder context, dus zonder filter. Vaak merkt niemand het, omdat de taak per klant wordt aangeroepen en toevallig goed gaat.
  • Een export of een import. Bulk-operaties omzeilen bijna altijd de gewone laag, want ze zijn geschreven voor snelheid.
  • Een relatie die één stap te ver loopt. factuur.klant.administratie — en die administratie was van iemand anders.

Elk van die vier is een vergissing van één regel. Het gevolg is niet een bug maar een datalek, en bij een boekhoud- of financieel product ook een meldplicht.

Een database of een schema per klant sluit die gaten wel, maar ruilt ze in voor beheer dat met elke nieuwe klant meegroeit. Die afweging, met de modellen naast elkaar in een tabel, staat in multi-tenant architectuur: welk isolatiemodel kies je?

De oplossing: twee sloten, niet één

Zet de scheiding daarom op twee lagen tegelijk.

De eerste is de gewone: een tenantfilter in de applicatie, zodat de dagelijkse code er niet over hoeft na te denken.

De tweede is row-level security in PostgreSQL. De database zelf weigert rijen terug te geven die niet bij de huidige tenant horen, ongeacht wie het vraagt. De sessie zet één variabele — app.current_tenant_id — en het beleid op de tabel doet de rest. Een rauwe query krijgt dan gewoon geen rijen terug. Een achtergrondtaak zonder context krijgt niets. Een import die de ORM omzeilt, krijgt niets. Mits hij aanstaat: een beleid dat er wel staat maar niets doet ziet er in een migratie identiek uit, en de vijf manieren waarop dat gebeurt staan in vijf gaten in tenant-scheiding.

Dat is het hele punt: de tweede laag beschermt je tegen de fouten die je in de eerste gaat maken. Niet tegen aanvallers van buiten — tegen jezelf, over een jaar, in een haastige vrijdagmiddag-fix.

Een query op klantgegevens: wat er terugkomt, met één slot en met twee

Staat row-level security ook echt aan, en is het niet alleen een beleid dat er wel staat maar niets doet?

  • Nee

    Is het tenantfilter van de applicatie op de query toegepast?

    • JaDe rijen van de huidige tenantDe dagelijkse code, via de scope in de ORM.
    • NeeEr kunnen rijen van een andere klant terugkomenEen rauwe query, een achtergrondtaak, een export of import, een relatie die één stap te ver loopt. Geen bug maar een datalek.
  • Ja

    Heeft de sessie app.current_tenant_id gezet?

    • NeeNul rijenEen achtergrondtaak zonder context krijgt niets.
    • JaAlleen de rijen van de huidige tenantOf het filter nu is toegepast of niet: de database weigert de rest, ongeacht wie het vraagt.

Staat het tweede slot aan, dan hangt het antwoord niet meer af van de vraag of het eerste is toegepast.

Zit er tussen platform en klant nog een partij — een administratiekantoor bijvoorbeeld — dan komt er een derde laag overheen: een restrictief beleid op app.current_firm_id. Een kantoor ziet zijn eigen klanten, een klant ziet alleen zichzelf, en die twee regels bijten elkaar niet omdat ze in de database staan en niet in een if.

Wat het kost

Eerlijk: het is niet gratis.

  • Elke verbinding moet de sessievariabele zetten. Vergeet je dat, dan krijg je nul rijen — hinderlijk, maar de goede kant op fout.
  • Migraties en beheerstaken hebben een expliciete manier nodig om eromheen te werken, en die moet zichtbaar zijn. Bij ons heet die functie letterlijk bypassRls, zodat je hem in een review niet over het hoofd ziet.
  • Er zit een klein prestatie-effect op, en het loont om je indexen op tenant_id te beginnen.

Bij elkaar is dat misschien twee dagen werk aan het begin van een project. Diezelfde scheiding er achteraf in bouwen is weken, plus de onzekerheid of je alles hebt gehad — en die onzekerheid gaat nooit meer weg.

De tests die het bewaken

Een isolatiemodel zonder test is een bewering. De test die ertoe doet is simpel en onaangenaam: maak twee tenants, zet data in allebei, en probeer vanuit de ene bij de andere te komen — via de ORM, via een rauwe query, via de achtergrondtaak, via de export.

Bij ons is dat een substantieel deel van de bijna vierduizend tests, en het is de categorie waar de nachtelijke agents als eerste dekking bij mogen schrijven. Niet omdat het spannend werk is, maar omdat het de enige categorie is waar een fout niet te repareren valt met een patch: als de data eenmaal bij de verkeerde klant is geweest, is het gebeurd.

De andere twee

Voor de volledigheid, want de vraag komt altijd: de andere twee onomkeerbare beslissingen zijn hoe je geld rekent en hoe je rechten modelleert.

Bedragen horen in hele centen te staan, niet in floats, en de tests horen op de uitkomst te controleren en niet op de code. Een afrondingsfout die twee jaar meeloopt is duurder dan het hele project — en je vindt hem meestal doordat een klant hem vindt.

Rechten horen bij een rol te horen en niet bij een lijstje vinkjes per gebruiker. Dat laatste voelt in het begin flexibel en is na twintig klanten niet meer uit te leggen, laat staan te testen.

Dat is de hele lijst. Alles daarbuiten mag je later veranderen. De drie naast elkaar, met wat ze vooraf en achteraf kosten en vier controles die je zelf kunt doen, staan in het overzicht van de drie onomkeerbare beslissingen.

En draait je product op PostgreSQL, dan liggen daar nog drie dingen naast deze vast — de indexen, hoe je één klant terugzet en hoe je migreert als het aantal klanten groeit. Die zes staan bij elkaar in multi-tenant SaaS op PostgreSQL.

Laten we praten

Wat gaan we bouwen?

Eén gesprek is genoeg om te weten of we bij elkaar passen. Vertel wat je voor je ziet — wij zeggen hoe snel dat kan, en of wij daar de juiste partij voor zijn.

Start het gesprek