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 · · 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.

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.

De oplossing: twee sloten, niet één

Bij booxx staat 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.

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.

In booxx zit er zelfs nog een derde laag overheen, omdat er een administratiekantoor tussen zit: 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.

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