Search code examples
ruby-on-railsauthorizationruby-on-rails-5cancancancancan

Ability: conditions dependent on the object (allow :create only for team_members)


Setup

A basic hotel setup where users are team members of a hotel. It uses cancancan and devise with Rails 5.2

rails g scaffold User name
rails g scaffold Hotel name
rails g scaffold TeamMembership user:references hotel:references
rails g scaffold Reservation starts_on:date ends_on:date hotel:references
rails g scaffold CheckIn hotel:references reservation:references

hotels are connected to users via has_many :users, through: :team_memberships. And vice versa for users to hotels.

config/routes.rb

resources :hotels do
  resources :reservations
  resources :check_ins
end

app/controllers/check_ins_controller.rb

class CheckInsController < ApplicationController
  before_action :authenticate_user!
  load_and_authorize_resource :hotel
  load_and_authorize_resource :check_in, :through => :hotel
[...]

app/models/ability.rb

[...]
can [:read, :destroy], CheckIn, hotel_id: user.hotel_ids
can [:create], CheckIn
[...]

Problem/Question

Somewhere in a view I have this code:

<% if can? :create, CheckIn %>
  <%= link_to 'Create Check-In', new_hotel_check_in_path(@hotel) %>
<% end %>

It should only be visible to team members of the @hotel.

The first line of the ability.rb works fine but the second line doesn't work because with that anybody can create a new check_in but only team_memberships should be able to create a new check_in for their hotel.

What is the best way to solve this? Obviously the link shouldn't be displayed but also the /hotels/:hotel_id/check_ins/new URL should not be accessible for anybody who is not a team member.


Solution

  • This is a common problem and this is where the business logic intersects the authorization logic.

    There are many opinions on this matter.

    1) Many people believe that this kind of intersection is unacceptable. They would advise you to do what you need this way (separating business and authorization logic)

    <% if can?(:create, CheckIn) && current_user.member_of?(@hotel) %>
      <%= link_to 'Create Check-In', new_hotel_check_in_path(@hotel) %>
    <% end %>
    

    2) If you are sure you need this you can do it like this:

    Add a new permission to Hotel model:

    can [:check_in], Hotel do |hotel|
      user.member_of?(hotel)
    end
    

    Then in the view:

    <% if can?(:create, CheckIn) && can?(:check_in, @hotel) %>
      <%= link_to 'Create Check-In', new_hotel_check_in_path(@hotel) %>
    <% end %>
    

    In the controller:

    class CheckInsController < ApplicationController
      # ...
      def new
        authorize! :check_in, @hotel
        # ...
      end
    end