Skip to content

Lesson 2 — BoardPolicy

Why a plain Ruby object

Pundit is the standard Rails authorisation gem and it’s excellent. But it adds indirection — a separate class hierarchy, DSL methods, and conventions to learn. A plain Ruby object is simpler to understand, easier to test, and fully under our control.

BoardPolicy answers one question per method: can this user do this thing to this board?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# app/policies/board_policy.rb
class BoardPolicy
  def initialize(user:, board:)
    @user  = user
    @board = board
  end

  def view?        = member?
  def create_card? = member?
  def edit_card?   = member?
  def delete_card? = member?
  def move_card?   = member?

  def edit_board?      = admin?
  def delete_board?    = admin?
  def manage_columns?  = admin?
  def manage_members?  = admin?

  private

  def membership
    @membership ||= @board.memberships.find_by(user: @user)
  end

  def member? = membership.present?
  def admin?  = membership&.admin?
end

The policy is deliberately simple — binary member/admin, no complex permission matrix. Note that card deletion is member? not admin? — any member can delete any card. As noted in the module intro, this is a known simplification.

Restart the server so it picks up the new policies folder.

Testing the policy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# test/policies/board_policy_test.rb
require "test_helper"

class BoardPolicyTest < ActiveSupport::TestCase
  def setup
    @board = boards(:one)
    @admin  = users(:one)   # Alice — admin
    @member = users(:two)   # Bob — member
    @guest  = User.new      # not a member
  end

  test "admin can view" do
    assert BoardPolicy.new(user: @admin, board: @board).view?
  end

  test "member can view" do
    assert BoardPolicy.new(user: @member, board: @board).view?
  end

  test "guest cannot view" do
    refute BoardPolicy.new(user: @guest, board: @board).view?
  end

  test "admin can manage columns" do
    assert BoardPolicy.new(user: @admin, board: @board).manage_columns?
  end

  test "member cannot manage columns" do
    refute BoardPolicy.new(user: @member, board: @board).manage_columns?
  end
end

Wiring policy into ApplicationController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Authentication

  allow_browser versions: :modern
  stale_when_importmap_changes
  layout false

  private

  def current_user
    Current.session&.user
  end
  helper_method :current_user

  def policy_for(board)
    BoardPolicy.new(user: current_user, board: board)
  end

  def authorize!(policy_method, board)
    unless policy_for(board).public_send(policy_method)
      redirect_to boards_path, alert: "You don't have permission to do that."
    end
  end
end

authorize! redirects with an alert rather than rendering a 403 page — simpler and friendlier. A dedicated error page can be added in the finishing touches module.

Enforcing policy in BoardsController

Update the boards controller to use membership-based lookup and enforce the policy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# app/controllers/boards_controller.rb
class BoardsController < ApplicationController
  before_action :set_board, only: [:show, :edit, :update, :destroy]
  before_action :authorize_view!,   only: [:show]
  before_action :authorize_edit!,   only: [:edit, :update]
  before_action :authorize_delete!, only: [:destroy]

  def index
    render Views::Boards::Index.new(boards: current_user.boards)
  end

  def show
    render Views::Boards::Show.new(board: @board,
                                   policy: policy_for(@board))
  end

  def new
    render Views::Boards::New.new(board: Board.new)
  end

  def create
    @board = current_user.owned_boards.build(board_params)
    if @board.save
      redirect_to board_path(@board), status: :see_other
    else
      render Views::Boards::New.new(board: @board),
             status: :unprocessable_entity
    end
  end

  def edit
    render Views::Boards::Edit.new(board: @board)
  end

  def update
    if @board.update(board_params)
      redirect_to board_path(@board), status: :see_other
    else
      render Views::Boards::Edit.new(board: @board),
             status: :unprocessable_entity
    end
  end

  def destroy
    @board.destroy
    redirect_to boards_path, status: :see_other
  end

  private

  def set_board
    @board = Board.find(params[:id])
  end

  def authorize_view!   = authorize! :view?,        @board
  def authorize_edit!   = authorize! :edit_board?,  @board
  def authorize_delete! = authorize! :delete_board?, @board

  def board_params
    params.expect(board: Views::Boards::Form.permitted)
  end
end

Board.find without scoping gives a 404 for non-existent boards and a 403-style redirect for boards where the user has no membership. This is the correct separation — record lookup vs access control.

Enforcing policy in ColumnsController

Columns are board structure — only admins can manage them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
  before_action :set_board_and_authorize

  def create
    @column = @board.columns.build(column_params)
    @column.save
    redirect_to board_path(@board), status: :see_other
  end

  def update
    column.update(column_params)
    redirect_to board_path(column.board), status: :see_other
  end

  def destroy
    column.destroy
    redirect_to board_path(column.board), status: :see_other
  end

  private

  def set_board_and_authorize
    @board = params[:board_id] ? Board.find(params[:board_id])
                                : column.board
    authorize! :manage_columns?, @board
  end

  def column
    @column ||= Column.find(params[:id])
  end

  def column_params
    params.expect(column: Views::Columns::ColumnForm.permitted)
  end
end

Enforcing policy in CardsController

Cards are board content — any member can create, edit, move, or delete:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
  before_action :set_board_and_authorize

  def create
    @column = Column.find(params[:column_id])
    @card   = @column.cards.build(card_params)
    @card.save
    redirect_to board_path(@column.board), status: :see_other
  end

  def update
    card.update(card_params)
    redirect_to board_path(card.column.board), status: :see_other
  end

  def destroy
    card.destroy
    redirect_to board_path(card.column.board), status: :see_other
  end

  private

  def set_board_and_authorize
    board = if params[:column_id]
      Column.find(params[:column_id]).board
    else
      card.column.board
    end
    authorize! :create_card?, board
  end

  def card
    @card ||= Card.find(params[:id])
  end

  def card_params
    params.expect(card: Views::Cards::CardForm.permitted)
  end
end

create_card? returns true for any member — it’s the least restrictive card policy method, so it covers create, edit, move, and delete correctly.