2

I have a system where a User can be associated with many Portals, however a user's permissions may differ between portals.

For example, a user might be able to see unpublished posts in one portal, but not in another portal.

For methods like show?, I can grab the portal off the record.

def show?
  portal = record.portal
  # logic to check whether, for this particular portal, 
  # this user has permission to view this record
end

However that solution doesn't work for policy scopes.

Is there any way I can, say, pass in the portal to policy_scope method in the controller?


One solution I've seen around the place is to set a (temporary) attribute against the user, so that policy methods can use it. e.g.

# model
class User < ActiveRecord::Base
  attr_accessor :current_portal
  ...
end

# controller
posts = portal.posts
current_user.current_portal = current_portal
policy_scope posts

# policy Scope
def resolve
  portal = user.current_portal
  # logic to scope these records by user's portal permissions
end

However this seems like a workaround, and I can definitely think of other scenarios where I'd like to be able to give authorisation logic more context as well, and I don't want this workaround to become a bad habit.

Does anyone have any suggestions?

Obversity
  • 567
  • 2
  • 9
  • 21
  • What is `logic to scope these records by user's portal permissions`? This information seems quite crucial to your problem. There are a couple of "workarounds" to pass additional context to Pundit, but (from experience) 95% of the time if you feel the need to use them then "you're doing it wrong". – Tom Lord May 09 '18 at 10:00
  • For example, the [pundit README shows another way of passing additional context, via the `pundit_user` method](https://github.com/varvet/pundit#additional-context), but also clearly says "If you find yourself needing more context than [the current user and domain model], consider whether you are authorizing the right domain model". In other words, the question boils down to: *What resource are you trying to authorize*? (And how does that currently work?) – Tom Lord May 09 '18 at 10:08
  • 1
    "What resource are you trying to authorize?" The problem isn't with authorizing records. The problem is with authorizing scopes. You're right though, I debated internally on whether to include any of the "logic to scope these records by user's portal permissions". Basically, I have a `PortalUser` join model between portals and users. So my current solution is to set `pundit_user` to the `PortalUser` rather than the `User`. However, this doesn't work for the various levels of admin users who have access to all portals, and don't have portal users themselves. – Obversity May 10 '18 at 00:03
  • By "resource", I did primarily mean a collection of records - i.e. The scope. Although I suspect the solution may impact individual records too. So, my question stands: can you show what the policy scope actually looks like? Maybe even the solution would be to change the definition of `current_user` globally across the whole application??! – Tom Lord May 10 '18 at 06:53
  • I can only hypothesise without seeing any concrete code. – Tom Lord May 10 '18 at 06:54

1 Answers1

-1

Pundit authorization classes are plain old ruby classes. Using pundit authorize method you can pass an object to be authorized and an optional query. When you for example use authorize method as

authorize @product, :update?

the authorize method will call the update? method on the ProductPolicy class. If no query passed, controller action name + ? mark will be set as the query. Take a look at Pundit authorize method definition.

So for passing extra parameters to Pundit authorize method, you should override authorize method with an extra argument:

module Pundit
  def authorize(record, query=nil, options=nil)
    query ||= params[:action].to_s + "?"

    @_pundit_policy_authorized = true

    policy = policy(record)

    if options.nil?
      raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
    else
      raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query, options)
    end
  end
end

Then in your policy class you could use this extra param

class ProductPolicy
  ...
  def update?(options)
    ...
  end

  def new?
    ...
  end
  ...
end

As you can see, you can have policy methods that accept an extra options argument.

Then you can use authorize method in your controllers in one of these ways:

authorize @product
authorize @product, nil, { owner: User.find(1) }
authorize @product, :some_method?, { owner: User.find(1) }
authorize @product, :some_method?
Aref Aslani
  • 1,560
  • 13
  • 27
  • 1
    IMO this is a very ugly workaround. You're changing the definition of `authorize`, which will (in a large rails application) infect 100s of files with a `nil` parameter in all of these method calls. And for what purpose? It's not even clear what problem you're trying to solve here; from experience, almost any real example can be solved without doing this, but I can't demonstrate how to do this without actually seeing the method body of your hypothetical `def update?(options)`. – Tom Lord May 09 '18 at 10:03
  • @TomLord No it's not required to add nil parameter in every method call. In the line containing `if options.nil?` it checks to send options parameter whenever it's needed. That whenever you use authorize method with options attribute, it expects that method on authorization class to accept one argument. – Aref Aslani May 09 '18 at 10:13
  • @TomLord After all I think it's better to put `options` argument at the end of argument list like `def authorize(record, query, options)` so that you won't have to change any line of your code anywhere. But when using authorize method you should call it in one of these ways: `authorize(@product, nil, extra_param)` or `authorize(@product, :update?, extra_param)`. – Aref Aslani May 09 '18 at 10:16
  • It's required on any method call that also specifies a `query` though, isn't it? Method signatures like: `foo(nil, nil, nil, something_else)` are an anti-pattern; a problem that's usually solved by using named parameters instead. But either way, implementing your solution in a large rails app will - like I said - infect many invocations across other controllers. – Tom Lord May 09 '18 at 10:17
  • No. Because if you don't send options parameter (if it's nil) it will call authorize method as it was before: without options parameter `raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)`. – Aref Aslani May 09 '18 at 10:19
  • As for your second suggestion above, I like the idea of `authorize(@product, :update?, extra_param)` the most -- but once again I go back to my statement that this should almost never be needed in the first place. This has been a feature request in Pundit many times, and the library author has repeatedly explained/demonstrated why it's not necessary if you just pass a correct domain model to be authorized in the first place. – Tom Lord May 09 '18 at 10:19
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/170677/discussion-between-tom-lord-and-aref-aslani). – Tom Lord May 09 '18 at 10:20
  • I've updated my answer this way: `authorize(resource, query, extra_param)` – Aref Aslani May 09 '18 at 10:33