35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Pundit Authorization: Productiepatronen voor Multi-Tenant SaaS Applicaties

Rails Pundit Authorization: Productiepatronen voor Multi-Tenant SaaS Applicaties

Roger Heykoop
Ruby on Rails, Security
Rails Pundit authorization voor multi-tenant SaaS: scopes, policies, role-based access en de productievalkuilen die teams in hun eerste security audit raken.

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_authorized vergeten in API-controllers. Aparte ApplicationController voor de API-namespace, after-action nooit gekoppeld, zes maanden ongeauthoriseerde endpoints. Plaats het overal of erf van een gedeelde basis.
  • policy_scope op 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 zelf Pundit.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.

#rails-pundit-authorization #pundit-policies #multi-tenant-saas #role-based-access-control-rails #pundit-vs-cancancan #rails-security #ruby
R

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 Touch

Share this article

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