RolePlays

RolePlays::Mixin (lib/role_plays/mixin.rb) is a declarative, role based authorization DSL. A policy class covers one resource: it declares roles, each role declares actions, and every action is a callable that answers a single question: is this allowed? A role also declares the attributes it may submit and the scopes it may read.

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

policy.can?(:destroy)                 # => true / false
policy.cannot?(:edit)                 # => !can?(:edit)

policy.permitted_attributes           # => %i[title description]
policy.permitted_attributes(:list)    # => %i[page per_page]

policy.scope(:list)                   # => a narrowed relation, or nil

role: is the only argument the policy asks for. Everything else is arbitrary: each keyword is kept as context and answered as a reader, so a policy is given what it actually talks about — see Context.

Every declared action also gets a can_<action>? predicate, so callers written against a hand written policy keep working — see Action predicates.

The role is a symbol rather than a user, so the policy never reaches into a token, a session or a decorator. Deriving the role from whoever is authenticated is the caller's job — see Supplying the role.

Everything is denied by default: an unknown role, an unknown action, or a nil role all return false from can?, an undeclared attribute label returns [], and an undeclared scope label returns nil.

Where it sits

RolePlays is a policy per resource, the way Pundit and Action Policy are: OrderPolicy, BoatPolicy, InvoicePolicy — one class per thing being authorized, built explicitly at the call site and asked about that one thing. There is no global ability object holding every rule in the application, and nothing is inferred: the policy you instantiate is the policy that answers.

Inside that class the rules are written in a DSL closer to CanCanCan than to Pundit's method per action: a permission is a declaration, not a method definition, so the ones that are a single expression stay a single line.

# Pundit / Action Policy: a method per action, one class per role or a chain of conditionals
class OrderPolicy < ApplicationPolicy
  def destroy?
    user.admin? || record.user_id == user.id
  end
end

# CanCanCan: declarative rules, but for every resource at once, keyed to the user
class Ability
  include CanCan::Ability

  def initialize(user)
    can :destroy, Order, user_id: user.id
    can :destroy, Boat, user_id: user.id
  end
end

# RolePlays: declarative rules like CanCanCan, scoped to one resource like Pundit, grouped by role
class OrderPolicy
  include RolePlays::Mixin

  context :current_role, :order, :orders

  role :user do
    action :destroy, -> { order.user_id == current_role.id }

    scope :list, -> { orders.where(user_id: current_role.id) }
  end

  role :admin do
    action :destroy, -> { true }
  end
end

What each borrowed idea is doing here:

  • From Pundit / Action Policy — the per-resource policy object, instantiated per question, with the record (and the relation) handed to it rather than looked up. Scopes live in the same class as the actions, so "who may see this" and "what may they see" are read together, and can_destroy? predicates mean a hand written policy can be swapped for one of these without touching callers.
  • From CanCanCan — the declarative rule list and the can? / cannot? vocabulary. action, permitted_attributes and scope are declarations collected at class definition time, so a role's permissions are a list to be scanned instead of a wall of method definitions.
  • Its own part — the role is a first class name a rule is filed under (with an :any fallback), not a condition inside a rule; and it is passed in as a symbol, so the policy never reads a user. Attributes and scopes are keyed by label, so one policy answers :create, :update and :list lists rather than one anonymous list per role.

Two deliberate omissions: rules are not translated into SQL — a scope is a relation you narrow yourself, so there is no accessible_by guessing a query from a hash of conditions — and there are no controller hooks or authorize callbacks. Building the policy and asking it is one line the caller writes.

Installation

bundle add role_plays

Or without Bundler:

gem install role_plays

Then require it — require "role_plays" — and include the mixin in a policy class. Ruby 3.2 or newer; the only runtime dependency is dry-struct, which the role structs are built from. Nothing here is tied to Rails: permitted_attributes is a list of symbols, and a scope is whatever the relation you passed in returns.

Anatomy

class OrderPolicy
  include RolePlays::Mixin

  context :current_role, :order, :order_relation

  role :user do
    action :create, -> { true }               # lambda handler
    action :destroy do                        # block handler
      order.user_id == current_role.id
    end

    permitted_attributes %i[title description]

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

  role :any do                                # fallback for every other role
    action :list, -> { true }

    permitted_attributes :list, %i[page per_page]
  end
end
  • role takes a role name matching the role: the policy is built with — any symbol you like, :user, :provider_location, :contractor, :admin.
  • action takes a name plus a lambda or a block. The handler takes no arguments; passing one that requires arguments raises ArgumentError at load time.
  • permitted_attributes takes an optional label plus a list, a lambda or a block — see Permitted attributes.
  • scope takes an optional label plus a lambda or a block narrowing the relation the policy was built with — see Scopes.
  • context names the keywords the policy is built with, so handlers read them by name — see Context.
  • Handlers are instance_exec'd against the policy, so they can use role, every context keyword and any helper method on the policy class.
  • The result of an action is coerced with !!, so returning a record, nil or a string is fine.

Role resolution and the :any fallback

An action is looked up on the current role first, then on :any. Permitted attributes and scopes resolve the same way, per label. This lets shared permissions be declared once instead of repeated per role.

class BoatPolicy
  include RolePlays::Mixin

  context :current_role, :boat

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

  role :user do
    action :create, -> { true }
    action :update, -> { boat.user_id == current_role.id }
  end

  role :provider_location do
    action :create, -> { true }
    action :update, -> { boat.provider_location_id == current_role.id }
    action :destroy, -> { true }
  end

  # Declares the role without granting anything beyond the :any actions
  role :contractor
end

With the example above:

role :list :create :destroy
user
provider_location
contractor
undeclared / nil

Declaring several roles at once

role takes more than one name — as a list or as an array — and files the same block under each of them. The block is evaluated once, so roles that answer a question the same way declare it together instead of repeating it. Declaring one of them again afterwards adds to what it already has, per action and per label, so the shared part and the distinct part are read one after the other:

class WorkOrderPolicy
  include RolePlays::Mixin

  context :current_role, :work_order, :work_orders

  # What working the location's jobs means, whichever of the two roles is asking
  role %i[provider_location contractor] do
    action :list, -> { true }
    action :show, -> { same_location? }
    action :log_time, -> { same_location? && work_order.in_progress? }

    permitted_attributes :list, %i[page per_page state assignee_id]

    scope :list, -> { work_orders.where(provider_location_id: current_role.provider_location_id) }
  end

  # ... and what only the location itself may do
  role :provider_location do
    action :create, -> { true }
    action :destroy, -> { same_location? && work_order.draft? }

    permitted_attributes %i[title description assignee_id scheduled_at]

    scope :unassigned, -> { scope(:list).where(assignee_id: nil) }
  end

  # ... and what a contractor may do instead
  role :contractor do
    action :update, -> { assigned? && work_order.in_progress? }

    permitted_attributes %i[state note]

    scope :list, -> { work_orders.where(assignee_id: current_role.id) }   # overrides the shared one
  end

  private

  def same_location?
    work_order.provider_location_id == current_role.provider_location_id
  end

  def assigned?
    work_order.assignee_id == current_role.id
  end
end
role :show :log_time :create :update permitted_attributes scope(:list)
provider_location shared shared title description … shared
contractor shared shared state note own assignments
  • The block is built once and filed under each name, so the two roles get the same declarations — not a shared object they could change for each other.
  • A later declaration of the same role merges into the earlier one and wins per action and per label, so :contractor extends the shared block with :update, replaces its :list scope and leaves everything else — including provider_location's copy of that scope — untouched.
  • The order reads as it is written: shared rules first, then what each role adds on top.
  • Names and prebuilt RolePlays::Mixin::Role structs can be mixed in the same call — see Composition instead of inheritance.

Action predicates

Declaring an action also defines a can_<action>? predicate on the policy, so the mixin answers the same messages a hand written policy does (can_create?, can_update?, can_destroy?, can_list?, …) and can replace one without touching its callers.

class BoatPolicy
  include RolePlays::Mixin

  context :current_role, :boat

  role :user do
    action :create, -> { true }
    action :destroy, -> { boat.user_id == current_role.id }
  end

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

policy = BoatPolicy.new(role: :user, current_role:, boat:)

policy.can_create?   # => can?(:create)
policy.can_destroy?  # => can?(:destroy)
policy.can_list?     # => can?(:list)      declared on :any
policy.can_import?   # => NoMethodError    no role declares :import
  • The predicates come from every role declaration, including shared roles, so an action declared on any one role is callable on the policy — the handler is still resolved for the current role at call time, and returns false when that role (and :any) does not declare it.
  • Only declared actions get a predicate; anything else raises NoMethodError rather than quietly answering false. Use can? for an action name computed at runtime.
  • A predicate written by hand on the policy class wins over the generated one.
  • define_action_predicates is public, so a policy resolving actions dynamically can declare the extra predicates itself: define_action_predicates(:accept, :decline).

Context

role: is the only argument new requires. Every other keyword is arbitrary — it is kept as context and answered as a reader, so the policy is given what it actually talks about instead of a fixed record/relation/options triple:

class OrderPolicy
  include RolePlays::Mixin

  context :user, :order, :order_relation

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

    permitted_attributes(:create) { %i[title description] + (order ? [:user_id] : []) }

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

OrderPolicy.new(role: :user, user: current_user, order: order, order_relation: Order.completed)
  • Any keyword new is given is readable by name — context does not have to be declared.
  • What the declaration adds is the nil: a declared name reads as nil when the caller leaves it out, so a handler can treat it as optional (order ? … : … above). An undeclared name raises NameError, so a typo in a handler is not quietly read as nil.
  • A reader written by hand on the policy wins over the generated one.
  • A keyword named after a method the policy already answers — can?, context, a helper of its own — raises ArgumentError, since its reader could never be reached.
  • The keywords are also available as a hash through context, and the declared names through .policy_context_keys.

Each policy names the things it talks about, so no two of them have to agree on a record / relation / options shape they do not share:

class WorkOrderPolicy
  context :current_role, :work_order, :work_orders
end

class InvoicePolicy
  context :current_role, :invoice, :invoices, :period
end

class SchedulerEventPolicy
  context :current_role, :event, :events, :calendar, :requested_at
end

class OrderPolicy
  context :current_role, :order, :orders, :child_account, :token_scopes
end

A handler then reads what it is about, and a caller passes only the keywords the question needs:

InvoicePolicy.new(role:, current_role:, invoice:).can?(:send)
InvoicePolicy.new(role:, current_role:, invoices: Invoice.kept, period: 1.month.ago..).scope(:report)

OrderPolicy.new(role:, current_role:, order:, child_account:).can?(:update)
OrderPolicy.new(role:, current_role:, token_scopes: current_user.claims[:scopes]).can?(:create)
OrderPolicy.new(role:).permitted_attributes(:list)

The keywords left out read as nil, so one policy answers a question about a record, about a relation and about a bare role without three constructors — and a handler that needs more context asks for it by name instead of being handed an options hash to dig through.

Permitted attributes

permitted_attributes declares the parameter list a role may submit, keyed by a label. The label is optional and defaults to :default, which is what policy.permitted_attributes returns when called without arguments.

class BoatPolicy
  include RolePlays::Mixin

  context :boat

  role :any do
    permitted_attributes :list, %i[page per_page search sort_name_asc]
  end

  role :user do
    permitted_attributes %i[name model_id year]                       # :default
    permitted_attributes :create, %i[name model_id year user_id]
    permitted_attributes(:update) do                                  # computed per instance
      own_boat? ? %i[name model_id year] : []
    end
  end
end
  • The attributes can be given literally (an array, a hash, or a single symbol) or computed by a lambda or a block. Callables are instance_exec'd against the policy exactly like action handlers, so role, the context keywords and helper methods are available.
  • The result is always wrapped in an array, so it can be handed straight to permit.
  • A label declared on no role — or a role with no attributes at all — yields [].
  • Declaring the same label twice overrides it; other labels are untouched.
policy = BoatPolicy.new(role: :user, boat:)

policy.permitted_attributes           # => %i[name model_id year]
policy.permitted_attributes(:create)  # => %i[name model_id year user_id]
policy.permitted_attributes(:list)    # => %i[page per_page search sort_name_asc]  (from :any)
policy.permitted_attributes(:import)  # => []

params.permit(policy.permitted_attributes(:create))

A role declares as many lists as it has actions to declare them for, so what may be sent is declared next to what may be done, and one policy answers every one of them:

role :provider_location do
  action :create, -> { true }
  action :update, -> { own_order? }
  action :invoice, -> { own_order? && order.completed? }

  permitted_attributes :create,  %i[user_id boat_id service_id comments]
  permitted_attributes :update,  %i[boat_id service_id comments status scheduled_at]
  permitted_attributes :invoice, %i[deposit discount tax_rate_group_id]
  permitted_attributes :list,    %i[page per_page state search]
end
policy = OrderPolicy.new(role:, current_role:, order:)

policy.can?(:invoice)                                # what may be done
params.permit(policy.permitted_attributes(:invoice)) # what may be sent doing it

A policy exposing one anonymous list per role has no answer for a role whose create form differs from its update form: it either takes the union of the two — the wider list quietly applying to both — or grows a second method (update_permitted_attributes) that only the callers knowing about it will use. A label is the missing name.

Nested attributes are declared the way permit expects them:

role :provider_location do
  permitted_attributes :update, [
    :description, :labor_rate,
    { technician_attributes: %i[id _destroy ids_mechanic_id], types_of_service: [] }
  ]
end

Scopes

scope declares how a role narrows a relation, keyed by a label exactly like permitted_attributes. The relation is one of the policy's own keywords, named by the policy rather than by the DSL.

class BoatPolicy
  include RolePlays::Mixin

  context :current_role, :boats, :period

  role :any do
    scope :list, -> { boats.where(archived: false) }
  end

  role :user do
    scope -> { boats.where(user_id: current_role.id) }                 # :default
    scope :list, -> { boats.where(user_id: current_role.id, archived: false) }
    scope(:report) do                                                  # computed per instance
      boats.where(user_id: current_role.id, created_at: period)
    end
  end

  role :provider_location do
    scope -> { boats.where(provider_location_id: current_role.id) }
  end
end
policy = BoatPolicy.new(role: :user, current_role:, boats: Boat.all)

policy.scope           # => Boat.where(user_id: 1)                      the :default label
policy.scope(:list)    # => Boat.where(user_id: 1, archived: false)
policy.scope(:unknown) # => nil                                         undeclared
  • A scope must be a lambda or a block — a literal relation would be evaluated at load time. It takes no arguments, and is instance_exec'd against the policy like every other handler.
  • An undeclared label — or a role with no scopes at all — yields nil. The policy names its own relation, so what "nothing is visible" means is left to the caller:
def boats
  BoatPolicy.new(role:, current_role:, boats: Boat.all).scope(:list) || Boat.none
end

A policy instance answers one scope per label, so build a new instance per relation.

Helper methods

Non trivial conditions read better as predicate methods on the policy. They are ordinary instance methods, so they are available to every handler.

class WorkOrderPolicy
  include RolePlays::Mixin

  context :current_role, :work_order

  role :provider_location do
    action :create, -> { tier_available? }
    action :update, -> { own_work_order? && editable_state? }
    action :destroy, -> { own_work_order? && work_order.draft? }
  end

  role :contractor do
    action :update, -> { assigned? && editable_state? }
  end

  private

  def own_work_order?
    work_order.provider_location_id == current_role.id
  end

  def assigned?
    work_order.assignee_id == current_role.id
  end

  def editable_state?
    work_order.state.in?(%w[draft unassigned assigned dispatched])
  end

  def tier_available?
    !current_role.provider.subscription_tier_inactive?
  end
end

The policy only knows the role name, so a handler needing the role record itself is given it as a keyword — current_role: by convention.

Composition instead of inheritance

Nothing is inherited: a policy answers for the roles it declares itself, and subclassing carries none of them. A role shared between policies is built once as a RolePlays::Mixin::Role and declared in each policy that wants it:

module SharedRoles
  READ_ONLY_ADMIN = RolePlays::Mixin::RoleBuilder.build(:admin) do
    action :list, -> { true }
    action :show, -> { true }

    permitted_attributes :list, %i[page per_page]

    scope :list, -> { invoices.all }
  end
end

class InvoicePolicy
  include RolePlays::Mixin

  context :current_role, :invoice, :invoices

  role SharedRoles::READ_ONLY_ADMIN

  role :provider_location do
    action :list, -> { true }
    action :send, -> { invoice.provider_location_id == current_role.id }
  end
end

Declaring the same role twice merges the actions, the attribute labels and the scope labels, and the later declaration wins, so a shared role is extended locally without affecting the policies it is shared with:

class CreditNotePolicy
  include RolePlays::Mixin

  context :credit_notes

  role SharedRoles::READ_ONLY_ADMIN

  role :admin do
    action :void, -> { true }                    # adds to the shared :list / :show
    action :show, -> { false }                   # overrides the shared handler

    permitted_attributes %i[reason amount]       # adds the :default label
    permitted_attributes :list, %i[page]         # overrides the shared :list label

    scope :list, -> { credit_notes.where(voided: false) }  # overrides the shared :list scope
  end
end

A policy that shares most of another one's rules composes the same Role structs; it does not subclass it.

Supplying the role

Nothing in the gem reads a user. role: is a symbol the caller passes, so where it comes from is yours to decide — a token claim, a column, a form, a constant:

OrderPolicy.new(role: current_user.role.to_sym, order:)             # straight off the user
OrderPolicy.new(role: OrderFormRoleSelector.new(form_token).role, order:)  # a public form
OrderPolicy.new(role: :provider_location, current_role: location, order:)  # a background job
OrderPolicy.new(role: :admin, order:)                               # a rake task

A selector of your own is the usual place to put that decision, and it is the place to refine a base role into the virtual roles the rules answer for separately — roles no token carries:

class RoleSelector
  def initialize(user)
    @user = user
    @role = user.role.to_sym
  end

  attr_reader :role

  # A :user acting through an access code — a token carrying scopes — is its own role
  def access_code
    @role = :access_code if role == :user && @user.claims[:scopes].present?
    self
  end

  # A :contractor whose directory does technician work is its own role
  def technician
    @role = :technician if role == :contractor && @user.current_role.technician?
    self
  end
end

RoleSelector.new(current_user).role                        # => :provider_location
RoleSelector.new(current_user).access_code.role            # => :access_code, else :user
RoleSelector.new(current_user).access_code.technician.role # refinements chain
  • A refinement only applies to the role it is about, so asking for one leaves every other role alone and the order they are asked in does not matter.
  • A policy asks for the distinctions it declares. One that treats access code users like any other user simply does not call access_code and never sees the role.

That is what makes a virtual role cheap: it is a role like any other, declared once, instead of a condition repeated in every handler that has to care.

class OrderPolicy
  include RolePlays::Mixin

  context :current_role, :order, :token_scopes

  role :user do
    action :create,  -> { true }
    action :destroy, -> { own_order? }

    permitted_attributes :create, %i[boat_id service_id comments]
  end

  role :access_code do
    action :create,  -> { token_scopes.include?('create_order') }
    action :destroy, -> { false }

    permitted_attributes :create, %i[boat_id service_id]
  end

  private

  def own_order?
    order.user_id == current_role.id
  end
end

OrderPolicy.new(role: RoleSelector.new(current_user).access_code.role,
                current_role: current_user.current_role,
                order:,
                token_scopes: current_user.claims[:scopes])

An unauthenticated request has no role to select from, and a nil role is fine to build a policy with — it leaves only the :any declarations, so nothing needs special casing:

role = current_user && RoleSelector.new(current_user).role

Building a policy

No factory lookup is needed, because the role picks the declarations inside the policy. new is the only entry point, and role: its only required argument:

OrderPolicy.new(role: :user, user: current_user, order: order).can?(:destroy)
OrderPolicy.new(role: :user, order: order).permitted_attributes(:create)
OrderPolicy.new(role: :user, order_relation: Order.all).scope(:list)
ReportPolicy.new(role: :user).can?(:list)

Pass only the keywords the question needs — the ones left out read as nil when they are declared with context.

role: is the role name as a symbol; strings are accepted and converted, so a value read straight off a record or a token can be passed through. A nil role leaves only the :any declarations. A handler that needs the role record itself is given it as a keyword. See Supplying the role.

Calling a policy

module Mutations
  module Orders
    class DestroyOrder < BaseMutation
      def resolve(id:)
        raise GraphQL::ExecutionError, 'Forbidden' if policy.cannot?(:destroy)

        # ...
      end

      private

      def order
        @order ||= Order.find(id)
      end

      def policy
        @policy ||= OrderPolicy.new(role: current_user && RoleSelector.new(current_user).access_code.role,
                                    current_role: current_user&.current_role,
                                    order:)
      end
    end
  end
end

Testing

Name the role under test and assert on can? and permitted_attributes — no user or token double is needed:

RSpec.describe OrderPolicy do
  subject(:policy) { described_class.new(role:, current_role:, order:) }

  context 'with a user role' do
    let(:role) { :user }
    let(:current_role) { create(:user_role) }
    let(:order) { create(:order, user: current_role) }

    it { expect(policy.can?(:destroy)).to be(true) }
    it { expect(described_class.new(role:, current_role:, order: create(:order)).can?(:destroy)).to be(false) }
    it { expect(policy.permitted_attributes(:create)).to eq(%i[title description]) }
  end

  context 'without a role' do
    let(:role) { nil }
    let(:current_role) { nil }
    let(:order) { create(:order) }

    it 'still applies the :any role' do
      expect(policy.can?(:list)).to be(true)
    end
  end
end

Note that a role is filed and compared as a symbol. new calls to_sym on whatever it is given, so a string — or anything answering to_sym, such as an ActiveSupport::StringInquirer — can be passed straight through; a handler comparing the role itself should compare symbols.

See spec/role_plays/mixin_spec.rb for the full behaviour of the DSL.

Why one policy instead of a class per role

The usual alternative splits one question across a file per role — OrderPolicies::User, OrderPolicies::Admin, a Scope class inside each, a module for the list two of them share and a factory mapping roles to classes. Five files before anything is answered:

# app/policies/order_policies/user.rb
module OrderPolicies
  class User < BasePolicy
    def can_create?
      true
    end

    def can_destroy?
      record.user_id == user.id
    end

    def permitted_attributes
      CommonAttributes.common_attrs
    end

    class Scope < BasePolicy::BaseScope
      def manage
        relation.where(user_id: user.id)
      end
    end
  end
end

What a policy gains by being one class:

  • The definition is in one place for all roles. Answering "who may destroy an order?" is one screen instead of three files opened side by side, and the roles are read against each other rather than one at a time. Adding a role adds a block, not a file, a class and a factory entry.
  • Common logic is easy to share. role %i[user provider_location] declares a rule once, :any declares one that holds for everyone, and a RolePlays::Mixin::Role struct shares one between policies — where a class per role can only share through inheritance from a base, which is why identical can_create? bodies get copied across sibling files.
  • Handlers read descriptive names, not record. context lets a policy name the things it talks about — order, orders, period, token_scopes — instead of handing every policy the same user / record / options triple to dig through.
  • It is not tied to current_user. The policy is told the role name, so the same rules answer for a request, a background job, a rake task, a public form with a selector of its own, or a spec that just writes role: :contractor — no user, no token, no stubbing.
  • Attributes and scopes gain names. Labels replace one anonymous permitted_attributes per role and a Scope class whose variants are method names the call site has to know.
  • Virtual roles become ordinary roles. A distinction like "a user acting through an access code" is declared once next to the role it differs from, instead of a condition written into every method that remembered to check it.
  • It encourages one liners. action :create, -> { true } against a three line def can_create?end, so a role's rules are a list to be scanned; anything longer becomes a named predicate (own_order?), which is where the reading actually happens.

Development

bin/setup            # install dependencies
bundle exec rake     # specs and RuboCop
bin/console          # an IRB session with the gem loaded

rake install installs the gem locally. To release a version, update RolePlays::VERSION and the CHANGELOG, then run bundle exec rake release, which tags the version, pushes the commit and the tag, and pushes the .gem to rubygems.org.

Contributing

Bug reports and pull requests are welcome. Please add specs alongside a change — bundle exec rake runs the suite and RuboCop, and both should be green.

License

Available as open source under the terms of the MIT License.