Rails Pundit Authorization: Productiepatronen voor Multi-Tenant SaaS Applicaties
In oktober belde een SaaS-oprichter me omdat hun pentest een bevinding had opgeleverd die zijn weekend om zeep had geholpen. Een ingelogde gebruiker van Acme Corp kon facturen van Globex ophalen door simpelweg het ID in de URL aan te passen. Klassieke IDOR. De fix was niet het lastige: een tenant scope toevoegen aan één query duurde tien minuten. Het lastige was dat exact dezelfde bug-vorm op drieëntwintig andere plekken voorkwam, en niemand had een manier om dat te weten zonder elke controller te lezen. Ze hadden geen authorization-laag. Ze hadden current_user-checks verspreid over helpers, controllers en een paar service objects. We hebben het in drie weken volledig herschreven bovenop Pundit.
Na negentien jaar Rails heb ik flink wat authorization-frameworks zien komen en gaan, en Pundit is degene waar ik in multi-tenant SaaS steeds opnieuw naar grijp. Niet omdat het indrukwekkend is, maar omdat het klein genoeg is om in je hoofd te passen en streng genoeg om elke regel een vaste plek te geven. Dit is de Rails Pundit authorization-gids die ik wou dat meer teams hadden gelezen vóór hun eerste SOC 2 audit.
Waarom Rails Pundit Authorization Wint voor SaaS
CanCanCan stopt elke regel in één grote Ability-klasse. Dat werkt totdat het niet meer werkt, en dan heb je een 600 regels lang bestand waar niemand meer aan wil komen. Action Policy is uitstekend en iets krachtiger, maar het plain-old-Ruby-model van Pundit is op dag één eenvoudiger uit te leggen aan een nieuwe collega.
Pundit is bovendien saai op de juiste manier. Elke resource krijgt een policy object. Elke policy heeft methodes die overeenkomen met controller-acties. Geen DSL, geen callbacks, geen verrassingen. Wil je weten wie een factuur mag updaten? Open InvoicePolicy en lees update?. Dat is het hele mentale model.
Voor multi-tenant SaaS is policy_scope de killer feature: een uniforme plek om te zeggen “deze gebruiker mag alleen records binnen zijn eigen tenant zien.” Zodra je het inhaakt, wordt de IDOR-bug uit het openingsverhaal structureel onmogelijk in elke controller die het gebruikt.
# Gemfile
gem "pundit", "~> 2.4"
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization
after_action :verify_authorized, except: :index, unless: :devise_controller?
after_action :verify_policy_scoped, only: :index, unless: :devise_controller?
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
flash[:alert] = "Je hebt geen rechten om deze actie uit te voeren."
redirect_back(fallback_location: root_path)
end
end
De verify_authorized en verify_policy_scoped after-actions zijn het stukje dat de meeste tutorials overslaan en het stukje dat je absoluut nodig hebt. Ze raisen in tests als een controller-actie vergeet authorize of policy_scope aan te roepen. Die ene regel maakt van Pundit geen conventie meer maar een garantie.
Rails Pundit Authorization Opzetten voor Multi-Tenancy
Het eerste wat elke multi-tenant Pundit-setup nodig heeft is een heldere definitie van “tenant.” Voor de meeste SaaS-apps is dat Account of Organization of Workspace. Kies één naam en houd je eraan. Ik gebruik hier Account.
Elk record dat aan een tenant toebehoort moet een account_id hebben. Elke controller moet toegang hebben tot current_account — meestal afgeleid van current_user.account of van een subdomein. De taak van de policy-laag is om de combinatie van current_user, current_account en het record onder de loep een ja-of-nee-antwoord op te leveren.
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
raise Pundit::NotAuthorizedError, "moet ingelogd zijn" unless user
@user = user
@record = record
end
def index? = false
def show? = false
def create? = false
def new? = create?
def update? = false
def edit? = update?
def destroy? = false
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NotImplementedError, "#{self.class} moet #resolve implementeren"
end
end
end
Default-deny is niet onderhandelbaar. Als een developer vergeet een methode te overriden, is het antwoord nee. Ik heb teams deze defaults zien omdraaien naar true “voor het gemak” tijdens de vroege ontwikkeling, en drie weken later live zien gaan in productie. Doe dit niet.
Tenant-Scoped Policies in de Praktijk
Zo ziet een InvoicePolicy eruit voor een SaaS met een tenant-model en een paar rollen per gebruiker (admin, member, viewer).
# app/policies/invoice_policy.rb
class InvoicePolicy < ApplicationPolicy
def index? = same_account?
def show? = same_account?
def create? = same_account? && (user.admin? || user.member?)
def update? = same_account? && (user.admin? || (user.member? && record.draft?))
def destroy? = same_account? && user.admin? && record.draft?
def permitted_attributes
base = %i[customer_id due_on currency notes]
base += %i[status approved_at] if user.admin?
base
end
class Scope < ApplicationPolicy::Scope
def resolve
scope.where(account_id: user.account_id)
end
end
private
def same_account?
record.account_id == user.account_id
end
end
Twee dingen om op te merken. Ten eerste: elke methode die een record raakt begint met same_account?. Dat is het dragende stuk. Zonder dat zou een admin in Acme een factuur van Globex kunnen updaten enkel omdat hij ergens admin is. Ten tweede: permitted_attributes is een Pundit-feature die de meeste teams te laat ontdekken — het laat de policy beslissen welke velden een controller mag mass-assignen, in plaats van die logica te dupliceren in params.permit(...).
De controller-koppeling blijft schoon:
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :set_invoice, only: %i[show update destroy]
def index
@invoices = policy_scope(Invoice).order(created_at: :desc).page(params[:page])
end
def show
authorize @invoice
end
def create
@invoice = Invoice.new(permitted_attributes(Invoice).merge(account: current_account))
authorize @invoice
@invoice.save ? redirect_to(@invoice) : render(:new, status: :unprocessable_entity)
end
def update
authorize @invoice
if @invoice.update(permitted_attributes(@invoice))
redirect_to @invoice
else
render :edit, status: :unprocessable_entity
end
end
private
def set_invoice
@invoice = Invoice.find(params[:id])
end
end
Merk op dat set_invoice niet scopet op account. Dat hoeft ook niet. De authorize @invoice-aanroep roept show? of update? aan, en beide checken same_account?. Als een gebruiker uit een andere tenant het ID raadt, raised Pundit NotAuthorizedError en de rescue in ApplicationController redirect ze weg. De IDOR is dood.
Voor index doe je hetzelfde van de andere kant: policy_scope(Invoice) voert Scope.resolve uit, wat de relation beperkt tot het account van de huidige gebruiker. Cross-tenant datalek in lijst-endpoints is de meest voorkomende SaaS-bug, en policy_scope is de beste mitigatie die ik ken.
Role-Based Access Control met Rails Pundit Authorization
Pundit levert geen rollensysteem — feature, geen bug. Gebruik wat past bij je app. Voor de meeste klanten gebruik ik enum role: { viewer: 0, member: 1, admin: 2 } op User, met helpers als member_or_admin?. Voor enterprise-klanten die per-resource rollen nodig hebben, gebruik ik een join-tabel — Memberships(user_id, account_id, role) — en injecteer ik het membership in de policy:
# app/policies/application_policy.rb
def membership
@membership ||= user.memberships.find_by(account_id: record.account_id)
end
def role
membership&.role
end
def admin? = role == "admin"
def member? = role == "member" || admin?
def viewer? = role == "viewer" || member?
viewer? is true voor members en admins, member? is true voor admins. Dit soort rolhiërarchie houdt elke policy-methode kort. update? wordt same_account? && member? in plaats van same_account? && (admin? || member?). Minder code, minder kans om een rol te vergeten.
Voor multi-tenant SaaS waar één gebruiker bij meerdere accounts hoort (de SaaS-droom) is het Memberships-model verplichte kost. Zet role niet op User. Daar krijg je spijt van zodra iemand admin is in één workspace en viewer in een andere.
N+1 Vermijden in Pundit Policy Scopes
De eerste keer dat je Pundit naar productie brengt ontdek je dat je policies te vaak de database raken. Een pagina met honderd facturen, elk met een kleine <%= link_to "Bewerken", edit_invoice_path(invoice) if policy(invoice).update? %>, instantieert honderd policy-objecten en roept update? honderd keer aan. Als update? current_user.memberships.find_by(...) raadpleegt, zijn dat honderd queries.
De fix is memoizen op controller- of view-niveau. Pundit memoizt de policy-instance al per record via policy(record), dus de aanroep gebeurt maar één keer per record. Het membership-lookup is het trage stuk. Cache het.
# app/policies/application_policy.rb
def membership
@membership ||= user.memberships
.find_by(account_id: record.account_id) || NullMembership.new
end
Voor lijstpagina’s preload je memberships één keer:
def index
@memberships_by_account = current_user.memberships.index_by(&:account_id)
@invoices = policy_scope(Invoice).includes(:customer)
end
En raadpleeg @memberships_by_account[invoice.account_id] vanuit een view-helper. Voelt klein. Op een echt klantendashboard ging de responsetijd van 700ms naar 90ms.
Pundit Policies Testen Zonder Boilerplate
Policies zijn het makkelijkste deel van een Rails-app om uitputtend te testen, en de plek waar uitputtende tests het meeste opleveren. Ik schrijf een permutatietest voor elke policy: elke rol, elke ownership-combinatie, elke actie.
# spec/policies/invoice_policy_spec.rb
require "rails_helper"
RSpec.describe InvoicePolicy do
subject { described_class.new(user, invoice) }
let(:account) = build_stubbed(:account)
let(:other) = build_stubbed(:account)
let(:invoice) = build_stubbed(:invoice, account: account, status: "draft")
context "admin in hetzelfde account" do
let(:user) { build_stubbed(:user, account: account, role: :admin) }
it { is_expected.to permit_actions(%i[index show create update destroy]) }
end
context "member in hetzelfde account" do
let(:user) { build_stubbed(:user, account: account, role: :member) }
it { is_expected.to permit_actions(%i[index show create update]) }
it { is_expected.to forbid_actions(%i[destroy]) }
end
context "admin in een ander account" do
let(:user) { build_stubbed(:user, account: other, role: :admin) }
it { is_expected.to forbid_actions(%i[index show create update destroy]) }
end
end
De pundit-matchers-gem geeft je permit_actions en forbid_actions. De cross-tenant context — “admin in een ander account” — is de test die je nooit mag overslaan. Het is de regressietest voor de IDOR waarmee dit artikel begon.
Productievalkuilen die het Waard Zijn om te Kennen
Een paar dingen die klanten in het wild gebeten hebben:
verify_authorizedvergeten in API-controllers. AparteApplicationControllervoor de API-namespace, after-action nooit gekoppeld, zes maanden ongeauthoriseerde endpoints. Plaats het overal of erf van een gedeelde basis.policy_scopeop een relation die al gefilterd is.policy_scope(Invoice.where(status: "paid"))werkt.policy_scope(account.invoices)werkt ook, maar is overbodig — de scope past het accountfilter opnieuw toe. Kies één pad en wees consequent.- Background jobs. Pundit is request-scoped. In jobs heb je geen
current_user. Geef de gebruiker expliciet door en roep zelfPundit.policy(user, record).update?aan. Nooit aannemen. - Admin-achterdeurtjes. “Superadmin”-rollen die
same_account?omzeilen. Verleidelijk voor support-tooling. Dodelijk als een token lekt. Gebruik een aparte admin-namespace, met eigen policies die eerlijk zijn over cross-tenant toegang. - Migratie van legacy auth. Bij het vervangen van verspreide
current_user-checks door Pundit doe je het controller voor controller en houd je de oude check in stand totdat de tests voor de nieuwe slagen. Big-bang herschrijvingen van authorization eindigen in incidenten.
Voor diepere achtergrond bij de omliggende stack, zie een legacy Rails codebase erven voor hoe je deze veranderingen veilig doorvoert, Rails technical due diligence voor waar een auditor naar zal kijken, en Rails feature flags voor het geleidelijk uitrollen van een nieuwe authorization-laag.
FAQ
Is Pundit beter dan CanCanCan voor Rails authorization?
Voor multi-tenant SaaS met meer dan een handvol resources: ja. CanCanCan centraliseert alles in Ability, wat onbeheersbaar wordt voorbij een paar dozijn regels. Pundit’s één-policy-per-resource schaalt lineair met je domein in plaats van kwadratisch.
Hoe verhoudt Pundit zich tot Action Policy?
Action Policy is sneller, heeft ingebouwde caching en pre-checks, en is de juiste keuze als je honderden policy-aanroepen per request hebt. Pundit is eenvoudiger en vertrouwder voor nieuwe Rails-collega’s. Mijn default voor SaaS onder een paar miljoen requests per maand is Pundit; ik switch naar Action Policy zodra policy-lookups in profiles opduiken.
Hoe handel ik Pundit authorization af in Rails API-mode?
Dezelfde setup, maar gebruik head :forbidden in user_not_authorized in plaats van een redirect, en zorg dat je API-base-controller Pundit::Authorization includeert. Draai verify_authorized overal — API’s zijn waar IDORs zich het liefst verstoppen.
Kan Pundit attribuut-niveau authorization aan?
Ja, via permitted_attributes. Definieer in de policy welke velden elke rol mag mass-assignen, en roep permitted_attributes(Model) aan in de controller in plaats van params.require(...).permit(...). De policy wordt zo de single source of truth voor zowel rij- als veldniveau-toegang.
Hulp nodig om de authorization in je Rails SaaS dicht te timmeren zonder kapot te maken wat al werkt? TTB Software is gespecialiseerd in Rails security, multi-tenant architectuur en incrementele migraties van kritieke infrastructuur. We doen dit al negentien jaar.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Rails Puma Tuning: Workers, Threads, Geheugen en Concurrency voor Productie Performance
April 24, 2026
Rails Solid Cache: Database-Backed Caching in Rails 8 Zonder Redis of Memcached
April 23, 2026
Postgres Autovacuum Tunen voor Rails: Stop Table Bloat en Transaction ID Wraparound in Productie
April 21, 2026
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation