0

Roles permission - Chuyển từ Cancancan sang Pundit

Gần đây ứng dụng của chúng tôi đã chuyển từ CanCanCan thành Pundit. CanCanCan là một gem tuyệt vời nhưng chúng tôi đã phát triển nó thêm nữa. Đây là những bài học khác nhau.

Thứ nhất, phải thừa nhận rằng CanCanCan rất dễ để bắt đầu và có sự tích hợp tuyệt vời với RailsAdmin, Devise và các gem khác. Tất cả các quyền được định nghĩa trong tệp ability.rb nhưng cùng với thời gian thì file này có thể phát triển khá lớn. Ngoài ra còn có các nhược điểm khác như không có khả năng xác định cấp phép mức trường dữ liệu và unit testing từ các mã code riêng lẻ khác.

Pundit tách quyền cá nhân vào các lớp điều khoản riêng lẻ mà có thể kế thừa từ những lớp điều khoản khác. Vì vậy, bạn coi chúng như là POROs với của các phương thức.

Grouping policies

Thông thường bạn có rất nhiều model mà cần phải chia sẻ cùng quyền. Vì vậy, có thể bạn không muốn tạo ra policy files cho từng models và lặp lại code. Kể từ policy file của bạn chỉ là lớp Ruby bạn có thể làm điều này:

# app/policies/application_policy.rb
class ApplicationPolicy
  # define common permissions here
end
class UserPolicy < ApplicationPolicy
  # customize permissions for various methods index?, show?, etc
  # call super if needed
end
class CommonPolicy < ApplicationPolicy
  ...
end

Model của bạn có thể trông sẽ như thế này. Chúng tôi đang sử dụng Mongoid nhưng thiết kế tương tự sẽ làm việc với ActiveRecord.

# app/models/user.rb
class User
  belongs_to :client
  # will automatically use UserPolicy
end
class Client
  has_many :accounts
  has_many :users
  def self.policy_class
    CommonPolicy # manuall specify policy
  end
end
class Account
  belongs_to :client
  def self.policy_class
    CommonPolicy
  end
end
class Company
  belongs_to :client
  def self.policy_class
    CommonPolicy
  end
end

Điều này có thể là một trường hợp tương đồng với Single Table Inhertiance. Thông thường các điều khoản cho các mô hình khác nhau có nguồn gốc hình thức models cơ bản là giống nhau, do đó bạn có thể chia sẻ các policy.

Hoặc bạn có thể tạo ra các chính sách riêng cho từng Client, AccountCompany và sau đó bạn sẽ không cần phải làm self.policy_class. Bạn cũng có thể chỉ định điều khoản cụ thể hơn cho Client, Account Company models nếu cần thiết.

class ClientPolicy < CommonPolicy
  ...
end
class AccountPolicy < CommonPolicy
  ...
end
class CompanyPolicy < CommonPolicy
  ...
end

Mapping roles to permissions

Để cho mọi thứ đơn giản chúng ta có thể làm một bảng UserClient và belongs_to client và user

class User
  has_one :user_client
end
class Client
  has_many :user_clients
end
class UserClient
  belongs_to :client
  belongs_to :user
  field :roles, type: Array
  extend Enumerize
  enumerize :roles, in: [:admin, :readonly_admin, :account_admin, :company_admin],
  multiple: true  
end
class UserClientPolicy < ApplicationPolicy
  ...
end

admin có thể chỉnh sửa client và thực hiện các quyền CRUD trên bản ghi client con.readonly_admin chỉ có thể xem tất cả bản ghi, account_admin có thể làm các thao tác CRUD trên tài khoản và company_admin thể làm tương tự cho các bản ghi company. Đối với điều này, chúng tôi cần tạo policy riêng cho Client, Account and Company models.

Ngoài ra hệ thống còn có vai trò mở rộng (cho người dùng nội bộ) được xác định trực tiếp trên User model. Chỉ người dùng nội bộ có thể tạo / hủy tạo ra client mới nhưng Client Admins có thể thay đổi các thuộc tính của Client.

class User
  extend Enumerize
  enumerize :roles, in: [:sysadmin, :acnt_mngr], multiple: true  
end

Điều này sẽ cung cấp cho người dùng nội bộ truy cập vào tất cả bản ghi.

class ApplicationPolicy
  def index?
    return true if @user.roles.include? ['sysadmin', 'acnt_mngr']
  end
  def show?
    index?
  end
  def update?
    index?
  end
  def edit?
    index?
  end
  def create?
    index?
  end
  def new?
    index?
  end
  def destroy?
    # must have higher level permissions  
    return true if @user.roles.include? ['sysadmin']
  end
end

Vì vậy, công việc này rất tốt cho việc cấp phép một ứng dụng lớn nhưng client phải được đặc tả một cách rõ ràng hơn. Ngoài ra khi chúng tôi đang trong show, edit, update hoặc destroy chúng ta có thể lấy được client từ các bản ghi. Trong số đó chúng ta có nhiều bản ghi và trong new / create bản ghi chưa hề tồn tại, vì vậy chúng tôi cần get client từ user.

class User
  def get_client_id
    user_client.client_id
  end
end
class ApplicationPolicy
  def get_client_id
    # or we could just always get client from user
    return @record.client_id if @record.try(:client_id)
    return @user.get_client_id
  end
end

Điều này sẽ cho truy cập vào bản ghi Client với quyền chỉ đọc thông qua chỉ mục và hiển thị cho admin và readonly_admin và thêm quyền truy cập edit / update cho các roles khác.

class ClientPolicy
  def index?
    return true if @user.user_clients.where(client: get_client_id)
    .in(roles: ['admin', 'readonly_admin']).count > 0
    super
  end
  def show?
    index?
  end
  def edit?
    return true if @user.user_clients.where(client: get_client_id)
    .in(roles: ['admin']).count > 0      
    super
  end
  def update?
    edit?
  end
  #  new?, create? and destroy? are not set so it uses ApplicationPolicy
end

Kiểm tra cho @user.user_clients.where(client: @record.client).in(roles: ...) hiện tại đang không DRY vì thế chúng ta có thể trích xuất nó vào lớp riêng biệt.

# app/services/role_check.rb
class RoleCheck
  def initialize user:, client:, roles: nil
    @user = user
    @client = client
    @roles = roles
  end
  def perform
    return true if @user.roles.include? :sysadmin
    roles2 = [:admin, @roles].flatten
    return true if @user.user_clients.in(client_id: @client)
      .in(roles: roles2).count > 0
  end
end
#
class ClientPolicy
  def index?
    RoleCheck.new(user: user, client: get_client_id,
      roles: [:client_admin, :readonly_admin]).perform
  end
end

Quyền cho Account và Company hơi khác một chút

class AccountPolicy
  def index?
    RoleCheck.new(user: user, client: get_client_id,
      roles: [:account_admin, :readonly_admin]).perform
    super
  end
  def show?
    index?
  end
  def edit?
    RoleCheck.new(user: user, client: get_client_id,
      roles: [:account_admin]).perform
    super
  end
  def update?
    edit?
    # same checks for new?, create? and destroy?
  end
end
class CompanyPolicy
  # similar checks using 'company_admin' role
end

Bạn cũng có thể sử dụng gem Rolify để trỏ users tới roles nhưng chúng ta đã có model UserClient vì những lý do khác vì vậy chúng ta thừa hưởng điều đó.

Beyond RESTful actions

Bạn bắt đầu với :index?, :show? ... nhưng tiếp theo chúng ta cần định nghĩa thêm các quyền mà chúng ta muốn điểu chỉnh. Chúng ta có thể nói user trở thành admin từ việc activate? một account

class AccountPolicy
  def activate?
    # no need to pass admin role as RoleCheck automatically includes it
    RoleCheck.new(user: user, client: @record.client).perform
  end
end

Những loại hành động tùy chỉnh thường sẽ được đặc tả cụ thể cho một model nhưng nếu chúng là chung cho nhiều model bạn có thể đẩy chúng vào lớp policy thấp và kế thừa từ nó trong đặc tả policy model.

Để kiểm tra các điều khoản tùy chỉnh bạn có thể tạo ra một hành động non-RESTful trong AccountsController của bạn.

class AccountsController < ApplicationController
  def activate
    authorize @account
    @account.update(status: 'active')
  end
end
# or to stick with traditional REST actions you create a separate controller
class Accounts::ActivateController < ApplicationController
  def update
    authorize @account
    @account.update(status: 'active')
  end
end

Tiếp theo bạn gọi xác thực.

Require authorize in application controller for all actions

Cá nhân tôi thích yêu cầu xác thực cho tất cả các hành động trong controller ngay cả khi tôi đặt def index? true; Kết thúc để cho mọi người truy cập.

class AccountsController < ApplicationController
  after_action except: [:index] { authorize @account }
  after_action only:   [:index] { authorize @accounts }
end

Headless policies

Giả sử bạn có trường report_admin cho phép người dùng chạy các báo cáo khác nhau từ bảng điều khiển.

class DashboardPolicy < Struct.new(:user, :dashboard)
  def index?
    RoleCheck.new(user: user, client: user.get_client_id,
    roles: [:report_admin]).perform  
  end
end
# somehere in the UI navbar
<%= link_to('Dashboard', dashboard_index_path) if policy(:dashboard).index? %> |

Kiểm tra đảm bảo rằng file policy của bạn chỉ chứa những quyền cơ bản. Khi bạn chạy rails g pundit:policy bảng điều khiển sẽ bao gồm các chỗ cho class Scope < Scope. Nếu không, bạn đó là vấn đề của github.

Pundit::NotDefinedError at /dashboard
unable to find policy `DashboardPolicy` for `:dashboard`

Scopes

Internal users có thể xem tất cả các records, client specific users chỉ có thể xem các accountcompany được phân bổ cho client đó.

class AccountPolicy < ApplicationPolicy
  ...
  class Scope < Scope
    def resolve
      if @user.roles.include? ['sysadmin', 'acnt_mngr']
        scope.all
      else
        scope.in(client_id: @user.get_client_id)
      end
    end
  end
end

Field level permissions

Đôi khi bạn cần phải xác định quyền truy cập vào trường cụ thể w / in record. Giả sử rằng chỉ sysadmin có thể sửa trường Client status.

class ClientPolicy < ApplicationPolicy
  def permitted_attributes
    if user.roles.include? :sysadmin
      [:name, :status]
    else
      [:name]
    end
  end
end
class ClientController < ApplicationController
  def update
    if @client.update_attributes(permitted_attributes(@client))
    ...
end

Bạn cũng muốn show /hide trường Status trong trang chỉnh sửa Client. Chỉ cần gọi phương thức permitted_attributes.

# app/views/clients/_form.html.erb
<% if policy(@client).permitted_attributes.include? :status %>
  <div class="form-inputs">
    <%= f.input :status %>
  </div>
<% end %>

Tôi đang làm việc với một giải pháp tốt hơn sử dụng CSS để hiển thị hoặc vô hiệu hóa các thuộc tính và đẩy logic vào decorator.

UI

Trong trình tạo giao diện người dùng erb / haml truyền thống, bạn có thể sử dụng kiểm tra được đề xuất trên trang Wiki của Pundit.

<% if policy(@account).update? %>
   <%= link_to "Edit account", edit_account_path(@account) %>
 <% end %>

Nhưng nếu bạn đang xây dựng Single Page Application? Chúng tôi đã sử dụng ActiveModelSerializers và các phương thức thêm linh hoạt với define_method. Thậm chí bạn có thể đẩy một số hành động thông thường vào ApplicationSerializer.

class AccountSerializer < ApplicationSerializer
  attributes :id, :name
  ...
  actions = [:index?, :show?, :new?, :create?, :edit?, :update?, :destroy?]
  attributes actions
  actions.each do |action|
    define_method(action) do
      policy = "#{object.class.name}Policy".constantize
      policy.new(current_user, object).send(action)
    end
  end
end

Controller của bạn trả về bằng đầu ra HTML hoặc JSON.

class AccountsController < ApplicationController
  def index
    @accounts = Account.all
    respond_to do |format|
      format.html
      format.json  { render  json: @accounts }
    end
  end
end

Bây giờ, frontend application JS có thể sử dụng đầu ra từhttp: // localhost: 3000 / accounts.json để kiểm tra quyền và show/ hide / disable các điều khiển UI thích hợp.

[
  {
  id: "1",
  name: "account 1",
  index?: true,
  show?: true,
  new?: false,
  create?: false,
  edit?: null,
  update?: null,
  destroy?: false
  },
]

Testing

Testing these policies interaction with the common RoleCheck code can get quite repetitive. That’s where stubbing can be a valuable tool. This will simulate passing user, client and roles parameters to RoleCheck and returning true or nil. Thử nghiệm các policies này tương tác với mã code RoleCheck bằng cách lặp đi lặp lại thao tác get. Đó là nơi có thể stubbing một công cụ có giá trị. Điều này sẽ mô phỏng các tham số người dùng, client và vai trò parameters để RoleCheck và trả về true hoặc nil.

# spec/policies/account_policy_spec.rb
permissions :index?, :show? do
  it 'valid' do
    rl = double('RoleCheck', perform: true)
    RoleCheck.stub(:new).with(user: user, client: client,
      roles: ['admin', 'readonly_admin']).and_return(rl)
    expect(subject).to permit(user, Account.new(client: client))
  end
  it 'invalid' do
    rl = double('RoleCheck', perform: nil)
    RoleCheck.stub(:new).with(user: user, client: client,
      roles: ['admin', 'readonly_admin']).and_return(rl)
    expect(subject).to permit(user, Account.new(client: client))
  end
end
permissions :create?, :update?, :new?, :edit?, :destroy? do
  it 'valid' do
    rl = double('RoleCheck', perform: true)
    RoleCheck.stub(:new).with(user: user, client: client,
      roles: ['admin']).and_return(rl)
    expect(subject).to permit(user, Account.new(client: client))
  end
  ...
end

Also checkout pundit-matchers gem.

Tài liệu tham khảo

http://blog.carbonfive.com/2013/10/21/migrating-to-pundit-from-cancan/ https://www.viget.com/articles/pundit-your-new-favorite-authorization-library http://through-voidness.blogspot.com/2013/10/advanced-rails-4-authorization-with.html https://www.sitepoint.com/straightforward-rails-authorization-with-pundit/ https://www.varvet.com/blog/simple-authorization-in-ruby-on-rails-apps/ https://github.com/sudosu/rails_admin_pundit

Bài viết được dịch từ nguồn http://dmitrypol.github.io/2016/09/29/roles-permissions.html#scopes


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.