Search code examples
ruby-on-railsauthorizationpundit

Pundit raising AuthorizationNotPerformedError on a loop where authorize object is called


I have two rails app : one is a "front-end app" responsible for displaying data, taking input from the user and sending data to the API (the second app). The second is an API dealing with database operations and sending JSON to the front end app.

I have an action where my user decides how many roomshe wants to create in his hotel and how many bedsshould he create in each room. The form on the front end app looks like that :

<h1>Add Rooms</h1>
<form action="http://localhost:3000/hotels/<%=params[:hotel_id]%>/rooms/multiple_create" method="post">
  <input name="authenticity_token" value="<%= form_authenticity_token %>" type="hidden">
  <div class="form-group">
    <%= label_tag 'room[room_number]', "Number of rooms" %>
    <input type="number" name="room[room_number]" required>
  </div>
  <div class="form-group">
    <%= label_tag "room[bed_number]", "Number of beds by room" %>
    <input type="number" name= "room[bed_number]" required>
  </div>
  <div class="form-group">
    <%= label_tag "room[content]", "Room Type" %>
    <input type="text" name= "room[content]" required>
  </div>
  <input type="submit">
    <% if @errors %>
    <ul class="list-unstyled">
      <%@errors.each do |error|%>
        <li class="has-error"><%=error%></li>
      <% end -%>
    </ul>
  <% end -%>
</form>

This form is linked to my RoomsController#multiple_create action on the front-end app that is responsible for sending the form data to the API :

class RoomsController < ApplicationController

  def multiple_new
  end

  def multiple_create
    @response =   HTTParty.post(ENV['API_ADDRESS']+'api/v1/hotels/'+ params[:hotel_id]+'/rooms/multiple_create',
    :body => { :room =>{
                  :room_number => params[:room][:room_number],
                  :bed_number => params[:room][:bed_number],
                }
             }.to_json,
    :headers => { 'X-User-Email' => session[:user_email], 'X-User-Token'=> session[:user_token], 'Content-Type' => 'application/json' } )
    # Erreur Mauvais Auth Token
    if @response["error"] == "You need to sign in or sign up before continuing."
      redirect_to unauthorized_path
    # erreur de validation
    elsif @response["error"]
      raise
      @errors = @response["errors"]
      render :multiple_new
    else
      raise
      redirect_to account_path(account_id)
    end
  end
end

I have a corresponding method in my room_controller.rb that is responsible for creating rooms, beds for each rooms and slots for each beds. On the API I am using Pundit for authorization.

  def multiple_create
    i = 0
    start_date = Date.today
    ActiveRecord::Base.transaction do
      while i < params[:room_number].to_i
        @room = @hotel.rooms.build(room_params)
        authorize @room
        if @room.save
          (0...params[:bed_number].to_i).each do
            @bed = @room.beds.create!
            for a in 0...60
              @bed.slots.create!(available: true, date: start_date + a.days )
            end
          end
          i+=1
        else
          break
        end
      end
    end
    if i == params[:room_number]
      render json: {success: "All rooms and beds where successfully created"}
    else
      render json: {error: "There was a problem during room creation process. Please try again later"}
    end
  end

Everytime I try to post on this method I get :

Pundit::AuthorizationNotPerformedError - Pundit::AuthorizationNotPerformedError:
  pundit (1.0.1) lib/pundit.rb:103:in `verify_authorized

It seems to me I am actually calling authorize @room before saving the new room during the loop. I have a method multiple_create in my room_policy.rb:

  def multiple_create?
    (user && record.hotel.account.admin == user) || (user && user.manager && (user.account == record.hotel.account))
  end

And in my API base_controller I have :

after_action :verify_authorized, except: :index

Why am I getting this pundit error here ?


Solution

  • Thats a very creative solution - however there is a much way simpler and better way to deal with inserting / updating multiple records.

    class Hotel
      has_many :rooms
      accepts_nested_attributes_for :rooms
    end
    

    accepts_nested_attributes_for means that you can create rooms with:

    @hotel = Hotel.new(
      rooms_attributes: [
         { foo: 'bar' },
         { foo: 'baz' }
      ]
    )
    
    @hotel.save # will insert both the hotel and rooms
    

    You would create a form like so:

    <%= form_for(@hotel) |f| %>
      <%= f.fields_for :rooms do |rf| %>
       <%= f.foo %>
      <% end %>
    <% end %>
    

    And handle it in your controller like so:

    class HotelsController
    
      def update
        @hotel.update(hotel_params)
        respond_with(@hotel)
      end
    
      private 
        def hotel_params
          params.require(:hotel).permit(rooms_attributes: [ :foo ])
        end
    end
    

    You can nest it deeper with multiple accepts_nested_attributes_for and fields for. But in general when you go more than one level down thats a serious code smell. Split it into multiple controllers instead.

    Note that you don't really need a create_multiple or update_multiple action.

    So back to the core of the question, how do I authenticate it? Keep it simple stupid.

    def update
      authorize @hotel
      # ...
    end
    

    and handle it in your HotelPolicy.

    def update?
      return false unless user
      record.account.admin == user || (user.manager && (user.account == record.account))
    end
    

    Edit

    Based on your description of what you want to do you could simply add a custom getter / setter on your Hotel model.

    class Hotel < ActiveRecord::Base
      has_many :rooms
    
      # custom getter for binding form inputs
      def number_of_rooms
        rooms.count
      end
    
      # custom setter which actually creates the associated records
      # this subtracts the existing number of rooms so that setting
      # hotel.number_of_rooms = 50 on an existing record with 20 rooms
      # will result in a total of 50 not 70.
      def number_of_rooms=(number)
        (number.to_i - number_of_rooms).times { rooms.new }
      end
    end
    

    When you create or update a record like so:

    [14] pry(main)> h = Hotel.new(number_of_rooms: 3)
    => #<Hotel:0x007feeb6ee2b20 id: nil, created_at: nil, updated_at: nil>
    [15] pry(main)> h.save
       (0.1ms)  begin transaction
      SQL (0.4ms)  INSERT INTO "hotels" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2016-02-08 13:17:06.723803"], ["updated_at", "2016-02-08 13:17:06.723803"]]
      SQL (0.2ms)  INSERT INTO "rooms" ("hotel_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["hotel_id", 3], ["created_at", "2016-02-08 13:17:06.726157"], ["updated_at", "2016-02-08 13:17:06.726157"]]
      SQL (0.4ms)  INSERT INTO "rooms" ("hotel_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["hotel_id", 3], ["created_at", "2016-02-08 13:17:06.728636"], ["updated_at", "2016-02-08 13:17:06.728636"]]
      SQL (0.1ms)  INSERT INTO "rooms" ("hotel_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["hotel_id", 3], ["created_at", "2016-02-08 13:17:06.730291"], ["updated_at", "2016-02-08 13:17:06.730291"]]
       (1.6ms)  commit transaction
    => true
    [16] pry(main)> 
    

    It will add build N number of associated rooms. You can then save the record normally.

    You would add this to your normal hotel update / create action by simply adding a form input for number_of_rooms:

    <%= form_for(@hotel) |f| %>
      <% # ... %>
      <%= f.label :number_of_rooms %>
      <%= f.number_field :number_of_rooms %>
    <% end %>
    

    This input will be in params[:hotel][:number_of_rooms]. You should add it to your strong parameters whitelist.

    Note that however you won't be able to set attributes per room. Follow the recommendation above for authorization.