0

Suppose we have the following setup in a ruby-on-rails (API) application:

class User < ActiveRecord::Base
  has_many :posts
  has_many :friends, class_name: User # Via a joins table....
end

class Post
  belongs_to :user
end

When visiting /users/:id/posts, I want the logged-in user to only be able to view this data, if they are friends.

The standard implementation for this in Pundit is to use a policy scope:

class PostsController < ApplicationController
  before_action :authenticate
  def index
    posts = policy_scope(Post.where(user_id: params[:user_id]))
    render posts
  end
end

class PostsPolicy < ApplicationPolicy
  class Scope < ApplicationPolicy::Scope
    def resolve
      scope.where(user: user.friends)
    end
  end
end

This will prevent a user from seeing non-friends' posts. However, it produces an API response of 200 Success (with an empty response body), not 403 Forbidden - which would be preferable for the FrontEnd to receive, and display an appropriate error message.


Here's one solution that does not work:

class PostsController < ApplicationController
  before_action :authenticate
  def index
    posts = policy_scope(Post.where(user_id: params[:user_id]))
    authorize posts # <- !!!!
    render posts
  end
end

class PostsPolicy
  def index?
    record.all? { |post| user.friends_with?(post.user) }
  end
end

Not only is this very inefficient, but if the user doesn't have any posts, then you'll always get a 200 Success response - which is still not ideal.

Similarly, it's not ideal to "return 403 if the response is empty" - because then you'd get error messages when viewing friends' posts, if they don't have any!


Here's a possible solution, but it feels wrong...

class PostsController < ApplicationController
  before_action :authenticate
  def index
    user = user.find(params[:user_id])
    authorize(user, :index_posts?) # <-- !!!!

    posts = policy_scope(Post.where(user: user))
    render posts
  end
end

class UsersPolicy
  def index_posts?
    user.friends_with?(record)
  end
end

(You could also use a more generic method name like UserPolicy#friends?, to the same affect.)

This works, but it feels like a mis-use of Pundit to be applying a UserPolicy method when authorising a Post resource!


Pundit does not allow passing additional arguments to policies. This has been a highly requested feature over the years. In particular, see this highly-relevant PR/discussion. In other words, what I'd like to be able to do is this:

class PostsController < ApplicationController
  before_action :authenticate
  def index
    user = User.find(params[:user_id])
    posts = policy_scope(Post.where(user: user))
    authorize(posts, user) # <- !!!! (not valid in Pundit)
    render posts
  end
end

class PostsPolicy
  def index?(other_user) # <- !!!! (not valid in Pundit)
    user.friends_with?(other_user)
  end
end

However, the feature was eventually conclusively rejected by the project maintainer in favour of using "name-spaced policies" and "form objects".


Hopefully this question is not too "opinionated", but what would you suggest? Is there a clean way to use the Pundit library whilst responding with appropriate 200 vs 403 appropriately?

Or, is there a good patch/fork/alternative I could use (preferably easy to migrate to, for a large project) that will better support my desired behaviour?

Tom Lord
  • 27,404
  • 4
  • 50
  • 77
  • [This](https://github.com/elabs/pundit/issues/351#issuecomment-310665553) is another possible workaround, possibly in conjunction with [this pending fix](https://github.com/elabs/pundit/pull/391) for name-spaced policy scopes? – Tom Lord Jul 05 '17 at 10:58
  • you are showing the posts#index page when they clicked on the link regardless if the current_user is a friend of users/:id or not right? Then I think you'd want a 200 then but then maybe not use the term "authorize" but more like I guess think of the posts as being "filtered"? But if you really don't show these posts#index page if not a friend (i.e. you dont show the links if not a friend) then I think 403 response is better. – Jay-Ar Polidario Jul 05 '17 at 12:29
  • I would also do something like what you said `authorize(user, :index_posts?)` but except that I'd name it as `authorize(user, :posts?)`, because technically speaking /users/:id/posts, the "posts" there is part of the resources of /users/:id which means posts#index is technically a resource of a member of users, though I agree that this does not seem to be RESTful-like. However, this approach works also for other nested resources. i.e. if you'll have in the future something like /categories/1/posts, then it is flexible enough to also implement a category_policy#posts? method in the same way. – Jay-Ar Polidario Jul 05 '17 at 12:34
  • On a side note, I am not really familiar with pundit, but we've been using [cancancan](https://github.com/CanCanCommunity/cancancan) to a great success. It handles [nested resources](https://github.com/ryanb/cancan/wiki/Nested-Resources) pretty well, although in my experience cancancan can get very confusing at the start of learning. – Jay-Ar Polidario Jul 05 '17 at 12:43
  • @Jay-ArPolidario This is a rails API project (i.e. all responses are in JSON; the Front-End is a separate JavaScript project). So the `403` response is particularly valuable in terms of providing a coherent API definition. I do prefer your naming of `UserPolicy#posts?` over my initial `index_posts?` though -- this seems more logical. – Tom Lord Jul 05 '17 at 12:44
  • @Jay-ArPolidario I have no intention of migrating to `CanCanCan`, however... This is a very large project, so the migration time alone makes this infeasible. It's also, in my opinion, a far more difficult/problematic library to work with than Pundit. (Building all your `abilities` in a one big class is a bad idea when your application is big ... ) – Tom Lord Jul 05 '17 at 12:48
  • ohh yeah this is an API! :) I think 403 is better just because the client-side will know from this response code that the current user is not a friend as opposed to returning a 200 empty response, which can be misleading (as either the current user is not a friend OR it just happened that the target user's posts is just empty). It also makes... – Jay-Ar Polidario Jul 05 '17 at 12:51
  • ...sense that the client side cannot view (forbidden) to view this posts#index resource if not a friend. I think it is the client-side's duty to handle a 403 like this, and that the client-side should not render the links to this resource in the first place because it will be forbidden as the current user is not a friend. – Jay-Ar Polidario Jul 05 '17 at 12:52
  • yeah it does get so biiiiig! haha. We resorted to segregating the ability.rb file by including a Module per resource. – Jay-Ar Polidario Jul 05 '17 at 12:54

0 Answers0