/ Tags: RAILS-CODE / Categories: SOLUTIONS

Scope Every Query To The Current User Without Polluting Every Method

Writing current_user.orders.find(params[:id]) everywhere works right up until someone writes Order.find(params[:id]) in a new controller and exposes another user’s data. The fix is to make the scoped path the only path.

Description

Load records through the association from the current user or account rather than through the model class. That turns an authorization check into a query condition, so a record belonging to someone else returns 404 instead of 403 — which also avoids leaking that the record exists. Centralise it in a single scoped_resource helper in ApplicationController so every controller uses the same path and there is one place to audit. ActiveSupport::CurrentAttributes makes the current user available to models and jobs without threading it through every method signature, and it resets between requests. Back it with a spec that iterates your controllers and asserts a foreign record 404s.

Sample input:

  GET /orders/999   # an order belonging to another account


Sample Output:

  404 Not Found  (not 403 — the existence of the record is not disclosed)

Answer

  # app/models/current.rb
  class Current < ActiveSupport::CurrentAttributes
    attribute :user, :account
  end

  # app/controllers/application_controller.rb
  class ApplicationController < ActionController::Base
    before_action :set_current
    before_action :authenticate_user!

    private

    def set_current
      Current.user    = authenticated_user
      Current.account = Current.user&.account
    end

    # The single scoped entry point every controller uses
    def scoped(model)
      raise "No current account" if Current.account.nil?

      Current.account.public_send(model.model_name.plural)
    end
  end

  class OrdersController < ApplicationController
    def index = @orders = scoped(Order).recent
    def show  = @order  = scoped(Order).find(params[:id])   # 404 for others

    def create
      @order = scoped(Order).build(order_params)
      # ...
    end
  end
  # The spec that keeps it honest
  RSpec.describe "tenant isolation", type: :request do
    it "404s on another account's order" do
      foreign = create(:order)
      sign_in create(:user)

      get "/orders/#{foreign.id}"
      expect(response).to have_http_status(:not_found)
    end
  end

Learn More

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More