varvet / pundit

Minimal authorization through OO design and pure Ruby classes
MIT License
8.26k stars 627 forks source link

Company-specific user permissions with Pundit #617

Closed tbcooney closed 3 years ago

tbcooney commented 5 years ago

I'm actually not sure if this is a Pundit or general permissions architectural problem, but I'm trying to setup a simple Pundit policy to restrict the actions a member within a company can perform. Users are joined as a Member to a company in a has_many, through: relationship. The Member model has a role attribute of owner or user.

All of the tutorials and documentation I see online for CCC and Pundit involve application-wide permissions. But I need more granular control.

For example, my application has hundreds of companies. Each company has a user who is an "owner" and they login each day to look at their earnings information. That owner/user wants to invite Joe Smith to the application so they can also look at the data and make changes. But they don't want Joe Smith to be able to see certain types of data. So we restrict Joe Smith's access to certain data for that company.

Given a User that is a member of a Store, how can I restrict the access in a controller for the User's association to the Store? Below is a Admin::MembersController where a store owner can invite other members. I want to check on the index that the records can only be shown to the user if their member association to the store has a role set to owner.

class Admin::MembersController < Admin::BaseController

  def index
    @company_members = current_company.members
    authorize([:admin, @company_members])
  end
end

Policy

class Admin::MemberPolicy < ApplicationPolicy

  def index?
    return [ record.user_id, record.store_id ].include? user.store_ids???
  end
end

User.rb

class User < ApplicationRecord
  # Automatically remove the associated `members` join records
  has_many :members, dependent: :destroy
  has_many :stores, through: :members
end

Member.rb

class Member < ApplicationRecord
  belongs_to :store
  belongs_to :user

  enum role: [ :owner, :user ]
end

Store.rb

class Store < ApplicationRecord
  has_many :members
  has_many :users, through: :members
end
tbcooney commented 5 years ago

I'm going to try to model it similarly to how @jnicklas outlines here: https://github.com/varvet/pundit/issues/188#issuecomment-52301296. I feel this is a little more difficult than the way CanCan implements where a hash of conditions can be passed to further restrict which records a permission applies to. Anyone care to chime in?

class ApplicationController
  include Pundit

  def pundit_user
    if session[:organization_id]
      UserContext.new(current_user, Organization.find(session[:organization_id]))
    else
      UserContext.new(current_user)
    end
  end
end
Linuus commented 5 years ago

Yeah, I think that would be a reasonable way to go about it. There's also some information about this in the Readme

tbcooney commented 5 years ago

Thanks for the appropriate label @Linuus and sorry if I'm polluting the Issues section.

I wanted to confirm with someone experienced whether this is an appropriate use-case for Pundit or if I consider a different way to implement authorization for my app? I've found my model setup seems to be quite standard, but given my use of the Member join table that associates a User to a specific Store (and holds the role attribute) I wanted to confirm I'm not contradicting the statement below.

If you find yourself needing more context than that, consider whether you are authorizing the right domain model, maybe another domain model (or a wrapper around multiple domain models) can provide the context you need

aximuseng commented 3 years ago

It's probably too late but I just came across this. I have the same set-up with enrollments vs roles. I (my original developer actually) did this sort of set-up:

class ApplicationPolicy
  attr_reader :enrollment, :record, :context

  def initialize(context, record)
    @user = context.user
    @enrollment = context
    @record = record
  end

  def scope
    Pundit.policy_scope!(user, record.class)
  end

  class Scope
    attr_reader :enrollment, :scope, :context

    def initialize(context, scope)
      @user = context.user
      @enrollment = context
      @scope = scope
    end

    def resolve
      scope
    end
  end
end

I then created methods in the enrollment like this which I can then use all through my policies:

enrollment.has_lead_role?(:administrator, :operations, :facilities)
enrollment.has_role?(:administrator, :owner)
enrollment.role_besides?(:guest)
tbcooney commented 3 years ago

It's probably too late but I just came across this. I have the same set-up with enrollments vs roles. I (my original developer actually) did this sort of set-up:

class ApplicationPolicy
  attr_reader :enrollment, :record, :context

  def initialize(context, record)
    @user = context.user
    @enrollment = context
    @record = record
  end

  def scope
    Pundit.policy_scope!(user, record.class)
  end

  class Scope
    attr_reader :enrollment, :scope, :context

    def initialize(context, scope)
      @user = context.user
      @enrollment = context
      @scope = scope
    end

    def resolve
      scope
    end
  end
end

I then created methods in the enrollment like this which I can then use all through my policies:

enrollment.has_lead_role?(:administrator, :operations, :facilities)
enrollment.has_role?(:administrator, :owner)
enrollment.role_besides?(:guest)

Very cool. I ended up rolling my own version of Six, which is how Gitlab manages their policies.