Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolatie met RLS Policies
Rails PostgreSQL Row-Level Security voor multi-tenant SaaS: hoe je RLS-policies opzet, sessievariabelen inzet en veilige tenant-isolatie in Rails 8 borgt.
De pull request zag er routineus uit. Een junior engineer had een klein intern rapportage-endpoint gemerged dat facturen van de huidige klant ophaalde. Reviewed, gemerged, gedeployed. Twee uur later kwam er een support-ticket binnen: “Waarom zie ik regelitems van een ander bedrijf in de export?” Ik opende de code en daar stond het — Invoice.where(created_at: params[:range]).find_each zonder tenant-scope. De variabele current_account was in elke controller correct gezet, maar deze ene regel had de globale scope gebruikt in plaats van de associatie. De junior engineer had precies gedaan wat tien jaar aan Rails-tutorials hem geleerd had. En we hadden data van de ene klant naar de andere gelekt.
Dat incident is de reden waarom ik nu Rails PostgreSQL Row-Level Security op elke multi-tenant SaaS die ik als fractional CTO draai standaard aanzet, hoe gedisciplineerd het team ook is. Tenant-scoping op applicatieniveau is nodig en gaat uiteindelijk een keer fout. RLS zet “de developer vergat te scopen” om van een datalek naar een query die nul rijen teruggeeft.
Na negentien jaar Rails kan ik met zekerheid zeggen: als je app multi-tenant is en iets bevat waarvoor een klant je zou aanklagen — PII, financiële gegevens, medische data, privégesprekken — dan hoort RLS aan te staan op die tabellen. Deze post is de exacte setup die ik uitrol, inclusief de onderdelen die je de eerste keer niet zomaar ziet aankomen.
Wat Rails PostgreSQL Row-Level Security Werkelijk Doet
Rails PostgreSQL Row-Level Security is een Postgres-feature (beschikbaar sinds 9.5, mature sinds 10) waarmee je een filter-policy aan een tabel hangt. Elke SELECT, UPDATE, DELETE en INSERT tegen die tabel wordt stilletjes door Postgres herschreven met de conditie uit de policy erbij. Als de policy zegt “alleen rijen waar account_id = current_setting('app.current_account_id')::bigint”, dan geeft een query voor SELECT * FROM invoices alleen rijen terug die horen bij het account_id dat op de huidige connectie is gezet.
Drie eigenschappen maken dit fundamenteel anders dan scoping op applicatieniveau:
- Het kan niet worden omzeild door application bugs. Een Rails-controller die vergeet
current_account.invoicesaan te roepen krijgt nog steeds gefilterde resultaten.Invoice.find(42)zonder scope geeftnilterug als factuur 42 bij een andere tenant hoort.raw SQLuitgevoerd viaActiveRecord::Base.connection.executewordt ook gefilterd, want het filter zit in Postgres, niet in Ruby. - Het wordt per connectie afgedwongen. De tenant-identiteit staat in een Postgres-sessievariabele (
SET LOCAL), die scoped is naar de huidige transactie. Wanneer je request klaar is en de connectie teruggaat naar de pool, is de setting verdwenen. De volgende request zet zijn eigen tenant-identiteit of heeft er geen. - Het is onzichtbaar voor de applicatie. ActiveRecord weet niet dat RLS bestaat. Je modellen, scopes en joins werken allemaal gewoon door. De enige Rails-code die moet veranderen is de middleware die de tenant-identiteit injecteert.
De security-garantie is betekenisvol. Bij het lek uit de openingsalinea zou RLS ervoor gezorgd hebben dat het endpoint nul rijen teruggaf voor de foute tenant — een bug, maar geen breach.
Wanneer Gebruik Je RLS Boven Applicatie-Scoping
Niet elke multi-tenant Rails-app heeft Rails PostgreSQL Row-Level Security nodig. Als jouw tenants je eigen interne afdelingen zijn en het ergste gevolg van een lek gênant is, dan volstaat applicatie-scoping met Pundit. Als je tenants externe klanten zijn en elk cross-tenant lek een juridische meldingsplicht triggert, voeg RLS toe.
De concrete triggers die maken dat ik RLS bij klanten verplicht stel:
- De applicatie verwerkt gereguleerde data (HIPAA, AVG bijzondere categorieën, PCI, SOX-gecontroleerde financiële data).
- Meerdere engineeringteams dragen bij aan de codebase en geen enkele reviewer ziet alle queries.
- Background jobs, exports of admin tools itereren door data buiten het normale controller-pad waar
current_accountgezet wordt. - De applicatie biedt een publieke API waar een gecompromitteerde klant-API-key gebruikt zou kunnen worden om naar tenant-IDs van anderen te vissen.
Het enige geval waarin ik geen RLS gebruik is wanneer de “tenant” eigenlijk een implementatiedetail is — bijvoorbeeld een workspace binnen het account van een gebruiker. Data die tussen de twee workspaces van één gebruiker lekt is geen breach, dat is een UX-bug. RLS is daar overkill.
Voor je verder gaat: RLS is een aanvulling op row-level multi-tenancy, het vervangt de keuze van je tenancy-patroon niet. Als je op aparte databases of Postgres-schemas draait is RLS onnodig — de isolatie zit dan op een andere laag.
RLS Opzetten in Rails 8
De hele setup kost ongeveer één migratie per tenant-owned tabel plus één middleware. Ik loop het door voor een typische invoices-tabel op een account-scoped SaaS.
Eerst de migratie die RLS aanzet en de policy aanmaakt:
class EnableRlsOnInvoices < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (account_id = current_setting('app.current_account_id', true)::bigint)
WITH CHECK (account_id = current_setting('app.current_account_id', true)::bigint);
SQL
end
def down
execute <<~SQL
DROP POLICY IF EXISTS tenant_isolation ON invoices;
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
SQL
end
end
Drie details in die SQL zijn heel belangrijk in productie:
FORCE ROW LEVEL SECURITYpast de policy ook toe op de table owner. Zonder dit is de Rails-databasegebruiker (die typisch owner is van de tabel als je migraties onder dat account hebt gedraaid) uitgezonderd van de policy en biedt RLS nul bescherming. Dit is de meest voorkomende fout die ik zie in RLS-setups van andere teams.USINGenWITH CHECKzijn twee losse clauses.USINGfiltert wat de huidige sessie mag lezen.WITH CHECKvalideert wat de sessie mag schrijven. Beide moeten dezelfde conditie hebben, anders krijg je asymmetrisch gedrag waarbij een tenant rijen kan inserten die hij daarna niet meer terug kan lezen.current_setting('app.current_account_id', true)— het tweede argumenttruebetekent “geef null terug als de setting ontbreekt” in plaats van een error te raisen. Gecombineerd met de::bigintcast levert een ongezette sessievariabeleNULL = bigintop, watNULLis, wat falsy is — zodat een unscoped query veilig nul rijen retourneert in plaats van te crashen.
Herhaal deze migratie voor elke tenant-owned tabel. In de praktijk schrijf ik een helper die ze genereert:
# lib/tasks/rls.rake
namespace :rls do
desc "Genereer een RLS-migratie voor TABLE=table_name TENANT_COLUMN=account_id"
task :generate do
table = ENV.fetch("TABLE")
column = ENV.fetch("TENANT_COLUMN", "account_id")
setting = ENV.fetch("SETTING", "app.current_account_id")
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
path = "db/migrate/#{timestamp}_enable_rls_on_#{table}.rb"
File.write(path, <<~RUBY)
class EnableRlsOn#{table.camelize} < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
ALTER TABLE #{table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE #{table} FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON #{table}
USING (#{column} = current_setting('#{setting}', true)::bigint)
WITH CHECK (#{column} = current_setting('#{setting}', true)::bigint);
SQL
end
def down
execute <<~SQL
DROP POLICY IF EXISTS tenant_isolation ON #{table};
ALTER TABLE #{table} DISABLE ROW LEVEL SECURITY;
SQL
end
end
RUBY
puts "Wrote #{path}"
end
end
Draai het als TABLE=documents bin/rails rls:generate en commit de migratie. Twaalf tabellen en twaalf migraties later is het volledige tenant-owned oppervlak beschermd.
Tenant-Identiteit Injecteren per Request
RLS doet niets tot Rails app.current_account_id op de connectie zet. De juiste plek daarvoor is een middleware of een ApplicationController around_action. Ik prefereer de controller omdat hij hard klaagt als een controller vergeet mee te doen.
class ApplicationController < ActionController::Base
around_action :with_tenant_scope
private
def with_tenant_scope
account_id = current_account&.id
raise "RLS: no tenant context" if account_id.nil? && requires_tenant?
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
"SET LOCAL app.current_account_id = #{account_id.to_i}"
)
yield
end
end
def requires_tenant?
true # override in controllers die legitiem geen tenant hebben (publieke API, health checks)
end
end
Een paar praktische opmerkingen over dat blok:
SET LOCALscoped de setting naar de huidige transactie. Wanneer de transactie eindigt — of hij nu gecommit, gerolld back of het einde van de request bereikt — is de setting weg. Deze eigenschap maakt het veilig om connecties te delen tussen requests via ActiveRecord’s connection pool.- De transactie is verplicht.
SET LOCALbuiten een transactie wordt stilletjes genegeerd. HetActiveRecord::Base.transaction-block hierboven zet er een op voor de hele request. Op PostgreSQL is dat vrijwel gratis — de transactie is niets meer dan eenBEGIN/COMMITom werk dat toch al zou plaatsvinden. account_id.to_ibeschermt tegen SQL-injectie. Interpoleercurrent_account.idnooit direct zonder type-coercie; een gemanipuleerd tenant-ID uit een signed cookie zou anders SQL kunnen injecteren.
Voor langlopende requests die meerdere databasecalls doen kan de ene omhullende transactie lock-contention op Postgres veroorzaken. Zie je dat in de praktijk, zet dan de waarde bij connection checkout in plaats van in een request-transactie — dat patroon laat ik in de volgende sectie zien voor background jobs.
Background Jobs, Admin Consoles en het Bypass-Probleem
Het controller-patroon breekt zodra een job buiten een request draait. Rails.application.executor.wrap zet een hoop Rails-machinerie op, maar weet niets van je tenant. Elke background job die tenant-data raakt moet expliciet de tenant-context zetten, en dat is makkelijk te vergeten.
Het patroon dat ik uitrol voor Solid Queue of Sidekiq jobs is een concern die perform wrapt:
module TenantScoped
extend ActiveSupport::Concern
class_methods do
def perform_for_tenant(account_id, *args)
set(account_id: account_id).perform_later(*args)
end
end
def perform(*args)
account_id = self.class.tenant_from_args(args) || raise("Job missing tenant")
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
"SET LOCAL app.current_account_id = #{account_id.to_i}"
)
super
end
end
end
class SendInvoiceReminderJob < ApplicationJob
include TenantScoped
def self.tenant_from_args(args)
Invoice.unscoped_by_rls.find(args.first).account_id
end
def perform(invoice_id)
invoice = Invoice.find(invoice_id) # RLS-scoped, veilig
InvoiceMailer.reminder(invoice).deliver_now
end
end
Het interessante stukje is Invoice.unscoped_by_rls. Soms heeft een job of admin-actie legitiem nodig om RLS te omzeilen — bijvoorbeeld de job die de tenant afleidt uit een binnenkomend ID voordat de tenant-context gezet kan worden. Postgres levert BYPASSRLS als role-attribuut precies voor dit geval:
-- In een database-migratie of manuele setup-stap
CREATE ROLE rails_admin BYPASSRLS;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO rails_admin;
GRANT rails_admin TO rails_app; -- de normale applicatiegebruiker
Een Rails-helper wisselt dan voor een specifiek block naar die rol:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.without_rls(&block)
connection.transaction do
connection.execute("SET LOCAL ROLE rails_admin")
yield
ensure
connection.execute("RESET ROLE")
end
end
def self.unscoped_by_rls
without_rls { all }
end
end
Ik gebruik dit spaarzaam. Het moet greppable zijn — elke without_rls-aanroep is een plek waar het RLS-vangnet niet werkt, en code review richt zich specifiek op die blokken. Bij één klant vonden en fixten we in de eerste maand drie legitieme gevallen en verwierpen we twee luie toepassingen.
Testen dat RLS Ook Écht Werkt
De waarde van Rails PostgreSQL Row-Level Security verdwijnt op het moment dat iemand vergeet het aan te zetten op een nieuwe tabel. Ik zet een spec neer die door elke tenant-owned tabel loopt en checkt dat de policy bestaat:
# spec/models/rls_spec.rb
require "rails_helper"
RSpec.describe "Row Level Security" do
# Tabellen die RLS aan moeten hebben
TENANT_OWNED_TABLES = %w[
invoices
documents
line_items
audit_logs
api_tokens
].freeze
TENANT_OWNED_TABLES.each do |table|
describe table do
it "heeft row level security aan staan" do
result = ActiveRecord::Base.connection.execute(<<~SQL).first
SELECT relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = '#{table}'
SQL
expect(result["relrowsecurity"]).to eq(true), "#{table}: RLS staat niet aan"
expect(result["relforcerowsecurity"]).to eq(true), "#{table}: RLS staat niet op FORCE"
end
it "heeft een tenant_isolation policy" do
count = ActiveRecord::Base.connection.execute(<<~SQL).first["count"]
SELECT COUNT(*) FROM pg_policies
WHERE tablename = '#{table}' AND policyname = 'tenant_isolation'
SQL
expect(count).to eq(1), "#{table}: mist tenant_isolation policy"
end
end
end
it "filtert cross-tenant reads" do
a1 = Account.create!(name: "A1")
a2 = Account.create!(name: "A2")
Invoice.without_rls { Invoice.create!(account: a1, amount_cents: 1000) }
Invoice.without_rls { Invoice.create!(account: a2, amount_cents: 2000) }
Invoice.transaction do
ActiveRecord::Base.connection.execute("SET LOCAL app.current_account_id = #{a1.id}")
expect(Invoice.count).to eq(1)
expect(Invoice.first.account_id).to eq(a1.id)
end
end
end
Die eerste spec is degene die bij klanten echte bugs heeft opgevangen. Een nieuwe engineer voegt een tabel toe, draait de migratie, ship naar productie, en de CI-failure vangt het voor het lek in productie staat.
Migraties, Rollbacks en RLS
bin/rails db:migrate draait onder de migrating user, wat typisch dezelfde gebruiker is als de draaiende applicatie. Als die gebruiker de table owner is dan geldt FORCE ROW LEVEL SECURITY ook voor migraties, en een data-fix-migratie die probeert UPDATE invoices SET status = 'refunded' te doen raakt nul rijen.
Het patroon dat werkt is migraties draaien onder de rails_admin-rol uit de vorige sectie, of migraties zo configureren dat ze aan het begin SET LOCAL ROLE rails_admin doen:
# config/initializers/rls_migration_bypass.rb
if defined?(ActiveRecord::Migration)
module RlsMigrationBypass
def migrate(direction)
if connection.adapter_name == "PostgreSQL"
connection.execute("SET LOCAL ROLE rails_admin") rescue nil
end
super
end
end
ActiveRecord::Migration.prepend(RlsMigrationBypass)
end
De rescue nil zit erin omdat de rol niet bestaat in lokale dev-omgevingen zonder verdere setup, en ik wil niet dat een verse clone van de repo faalt op bin/rails db:setup. In productie bestaat de rol en draait de migratie ongefilterd.
Test met strong_migrations als je dat gebruikt — sommige data-backfills die eerder werkten worden na het aanzetten van RLS op een tabel stiekem no-ops, en de backfill-migratie meldt “0 rows updated” in plaats van het echte getal. Niet gevaarlijk, maar verwarrend tot je weet dat je erop moet letten.
Performance: Wat RLS Eigenlijk Kost
De performance-overhead van Rails PostgreSQL Row-Level Security is kleiner dan de meeste mensen verwachten, maar niet nul. Een benchmark die ik draaide op een productiedatabase van een klant (Postgres 16, invoices-tabel met 8 miljoen rijen, account_id geïndexeerd):
SELECT * FROM invoices WHERE id = ?— 0,8 ms zonder RLS, 0,9 ms met RLS. 12% overhead.SELECT * FROM invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 50— 4,2 ms zonder RLS, 4,4 ms met RLS. 5% overhead. Het RLS-filteraccount_id = ...wordt al door de query afgedekt, dus de planner gebruikt de index sowieso.SELECT COUNT(*) FROM invoices(ongefilterd vanuit een admin-context) — 42 ms zonder RLS, 12 ms met RLS. RLS maakte het sneller omdat er eenaccount_id-filter bijkwam dat de index gebruikte.
De vuistregel: RLS kost bijna niets als de policy-conditie al door de natuurlijke filters van de query wordt afgedekt (het gangbare geval in een goed gescopte Rails-app), en kost een fractie van een milliseconde als hij een filter toevoegt aan een query die er nog geen had. Het enige geval waar ik heb gezien dat het pijn deed was op tabellen die zonder tenant-filter werden bevraagd en op sequential scans leunden — RLS dwingt de planner dan de tenant-index te gebruiken, wat meestal precies is wat je wilde.
Houd pg_stat_statements in de gaten nadat je RLS hebt aangezet. De total_exec_time op individuele queries verandert nauwelijks; de query fingerprints veranderen wel omdat Postgres de policy-conditie nu in de genormaliseerde querytekst opneemt.
De Faalmodus die Alles Rechtvaardigt
Zes maanden nadat we RLS bij de klant uit de openingsalinea hadden uitgerold, schreef een andere junior engineer een ander endpoint met een andere unscoped query. Deze keer zei het support-ticket: “De export is leeg maar ik weet dat ik facturen heb.” Ik opende de code, zag dezelfde bug en fixte hem in een vijf-minuten follow-up-PR. Geen incidentrapport. Geen klantmelding. Geen juridische review. Gewoon een bug-ticket dat dezelfde dag werd gesloten.
Dat is de trade die Rails PostgreSQL Row-Level Security je oplevert. Het fixt de bug niet. Het zet de bug om van een datalek in een kapotte feature. In een bedrijf dat elke dag ship’t en engineers heeft van wisselende seniority is dat het verschil tussen een slechte middag en een heel slecht kwartaal.
FAQ
Werkt Rails PostgreSQL Row-Level Security met connection pooling?
Ja, en dat is precies de eigenschap die maakt dat het überhaupt werkt. SET LOCAL scoped de setting naar de huidige transactie. Als de request klaar is en de connectie teruggaat naar ActiveRecord’s pool (of naar PgBouncer in transaction mode), is de setting weg. De volgende request die die connectie oppikt zet zijn eigen tenant-identiteit. Draai je PgBouncer in session pooling mode, dan blijft de setting binnen een sessie hangen tussen queries, maar wordt hij nog steeds gewist als de connectie terug naar de pool gaat.
Kan ik RLS combineren met read replicas?
Ja. RLS-policies zitten in het database-schema en worden automatisch naar fysieke replicas gerepliceerd. De applicatiecode die app.current_account_id zet moet op elke connectie draaien, ook op read-replica-connecties — Rails 8’s multiple-database-routing regelt dit netjes omdat de middleware de variabele op ActiveRecord::Base.connection zet, wat de primary is; voor reads moet je het block ook wrappen met een set_local op de replica-connectie, of de tenant-identiteit via een gedeelde methode op ApplicationRecord.connected_to zetten.
Wat gebeurt er als ik vergeet RLS aan te zetten op een nieuwe tabel?
Niets zichtbaars. De tabel geeft ongefilterd rijen terug aan elke connectie, en je applicatie-scoping is de enige verdediging. Daarom raad ik de geautomatiseerde spec uit de test-sectie aan — die vangt ontbrekende RLS op CI-tijd. De lijst met tenant-owned tabellen is klein en wordt handmatig gereviewd; de spec forceert dat de lijst overeenkomt met de werkelijkheid.
Beschermt RLS tegen SQL-injectie?
Deels. Als een aanvaller SQL injecteert die direct invoices bevraagt, blijft RLS filteren naar de huidige tenant. Dat is een betekenisvolle defense-in-depth-winst. Waar RLS niet tegen beschermt is een aanvaller die de SET LOCAL app.current_account_id = X-statement kan injecteren — want dan kiest hij de tenant. Je applicatiecode mag daarom nooit onbetrouwbare waarden in SET LOCAL interpoleren en moet het tenant-ID altijd met .to_i of een bind parameter coercen.
Bouw je een multi-tenant Rails-SaaS en zit je met de vraag hoe je cross-tenant datalekken voorkomt? TTB Software helpt teams Row-Level Security implementeren, hun tenant-isolatie auditen en een database-niveau vangnet onder applicatie-scoping leggen. Negentien jaar Rails, en ik heb RLS uitgerold op elke high-stakes multi-tenant app die ik de laatste vijf jaar heb gebouwd.
Related Articles
Rails 8 Solid Cache: Productieopzet, TTL-strategieën en Migreren vanaf Redis
Rails 8 Solid Cache in productie: opzetgids met databasesizing, TTL- en evictiestrategieën, migreren vanaf Redis en m...
Rails Thruster: Vervang Nginx door de Ingebouwde HTTP/2-proxy van Rails 8 in Productie
Rails Thruster vervangt Nginx als HTTP/2-proxy in Rails 8 productie. Configuratiegids: TLS via ACME, compressie, Kama...
Rails LLM Observability: Prompts, Latentie en Tokengebruik Tracen in Productie met Langfuse
Rails LLM observability-gids: trace prompts, latentie en tokenkosten in productie met Langfuse en ActiveSupport::Noti...