Module: RolePlays::Mixin

Defined in:
lib/role_plays/mixin.rb

Overview

Declarative, role based authorization DSL.

class OrderPolicy
include RolePlays::Mixin

context :user, :order, :order_relation

role :user do
  action :create, -> { true }
  action :destroy do
    order.user_id == user.id
  end
  action :edit do
    order.user_id == user.id && order.completed?
  end

  permitted_attributes %i[title description]
  permitted_attributes :list, %i[page per_page]
  permitted_attributes(:create) do
    %i[title description] + (order ? [:user_id] : [])
  end

  scope :list, -> { order_relation.where(sent: true) }
  scope -> { order_relation.where(user_id: user.id) }
end

# Roles listed together share the block, each one getting the same declarations
role %i[user access_code] do
  action :list, -> { true }

  scope -> { order_relation.where(user_id: user.id) }
end

role :admin do
end

role :any do
  action :list, -> { true }
end
end

policy = OrderPolicy.new(role: :user, user: current_user, order: order,
                       order_relation: Order.completed)

policy.can?(:destroy)               # => true / false
policy.permitted_attributes(:list)  # => %i[page per_page]
policy.scope(:list)                 # => Order.completed.where(sent: true)

Every declared action also gets a can_<action>? predicate — can_create?, can_destroy? — so a policy can replace a hand written one without touching its callers.

role: is the only argument the policy asks for. Every other keyword is arbitrary: it is kept as context and answered as a reader, so a policy is given what it actually talks about — user, order, order_relation — rather than a fixed record/relation/options triple. context declares those names, which makes them optional: a declared name reads as nil when the caller leaves it out, while an undeclared one raises, so a typo in a handler is not read as nil.

The role is a symbol such as :user or :provider_location, selected from the authenticated user by the caller — the policy is told which role it answers for instead of resolving it from a user itself, so the same rules answer for a request, a background job or a spec.

Roles are stored per class as RolePlays::Mixin::Role structs. An action is looked up on the current role first and falls back to the :any role, so shared permissions can be declared once. Unknown role/action pairs are denied. Nothing is inherited: a policy declares the roles it answers for, and a role shared between policies is passed around as a Role struct built with RoleBuilder.

Permitted attributes and scopes are declared the same way, keyed by a label (:default when omitted) and resolved with the same role/:any fallback. An unknown attributes label yields an empty list, an unknown scope label yields nil — narrowing a relation to nothing is left to the caller, the only side that knows which relation the policy was built around.

A policy is built once and never mutated afterwards. Action, attribute and scope bodies take no arguments and are evaluated against the policy instance, which gives them access to role, to every context keyword and to any helper method defined on the policy class itself. The DSL is one module on purpose — the declarations, the lookups and the readers they feed are one contract, so it is read top to bottom rather than split across files.

Defined Under Namespace

Modules: ClassMethods, ContextReaders Classes: Role, RoleBuilder

Constant Summary collapse

ANY_ROLE =

Role every other role falls back to for actions it does not declare itself.

:any
DEFAULT_ATTRIBUTES_LABEL =

Label a permitted_attributes declaration is filed under when none is given.

:default
DEFAULT_SCOPE_LABEL =

Label a scope declaration is filed under when none is given.

:default

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name, *args) ⇒ Object (private)

Reads a context keyword — new(role: :user, order:) answers order — so handlers can name what they need. An unknown name still raises NameError, so a typo is not read as nil.



364
365
366
367
368
# File 'lib/role_plays/mixin.rb', line 364

def method_missing(name, *args)
  return super unless args.empty? && context.key?(name)

  context[name]
end

Instance Attribute Details

#contextObject (readonly)

Returns the value of attribute context.



314
315
316
# File 'lib/role_plays/mixin.rb', line 314

def context
  @context
end

#roleObject (readonly)

Returns the value of attribute role.



314
315
316
# File 'lib/role_plays/mixin.rb', line 314

def role
  @role
end

Class Method Details

.included(base) ⇒ Object



196
197
198
# File 'lib/role_plays/mixin.rb', line 196

def self.included(base)
  base.extend(ClassMethods)
end

Instance Method Details

#can?(action) ⇒ Boolean

Returns:

  • (Boolean)


328
329
330
331
332
333
# File 'lib/role_plays/mixin.rb', line 328

def can?(action)
  handler = self.class.action_handler(role, action.to_sym)
  return false if handler.nil?

  !!instance_exec(&handler)
end

#cannot?(action) ⇒ Boolean

Returns:

  • (Boolean)


335
336
337
# File 'lib/role_plays/mixin.rb', line 335

def cannot?(action)
  !can?(action)
end

#initialize(role:, **context) ⇒ Object

The role is the name a role declaration was filed under, nil standing for no role at all, which leaves only the :any declarations. It is the only argument the policy asks for — every other keyword is kept as context and read back by name:

OrderPolicy.new(role: :user, user: current_user, order: order, order_relation: Order.all)


321
322
323
324
325
326
# File 'lib/role_plays/mixin.rb', line 321

def initialize(role:, **context)
  reject_shadowing_context!(context)

  @role = role&.to_sym
  @context = context.freeze
end

#permitted_attributes(label = DEFAULT_ATTRIBUTES_LABEL) ⇒ Object

Attributes the current role may submit for the given label, always as an array ready to be handed to ActionController::Parameters#permit



341
342
343
344
345
346
# File 'lib/role_plays/mixin.rb', line 341

def permitted_attributes(label = DEFAULT_ATTRIBUTES_LABEL)
  handler = self.class.permitted_attributes_handler(role, label.to_sym)
  return [] if handler.nil?

  wrap_attributes(instance_exec(&handler))
end

#scope(label = DEFAULT_SCOPE_LABEL) ⇒ Object

The relation narrowed to what the current role may see under the given label, or nil when neither that role nor :any declares the label. The policy carries its relation in a keyword of its own naming, so saying "nothing is visible" is left to the caller:

OrderPolicy.new(role:, order_relation: Order.all).scope(:list) || Order.none


353
354
355
356
357
358
# File 'lib/role_plays/mixin.rb', line 353

def scope(label = DEFAULT_SCOPE_LABEL)
  handler = self.class.scope_handler(role, label.to_sym)
  return nil if handler.nil?

  instance_exec(&handler)
end