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?