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_attributesandscopeare 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
:anyfallback), 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,:updateand:listlists 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
roletakes a role name matching therole:the policy is built with — any symbol you like,:user,:provider_location,:contractor,:admin.actiontakes a name plus a lambda or a block. The handler takes no arguments; passing one that requires arguments raisesArgumentErrorat load time.permitted_attributestakes an optional label plus a list, a lambda or a block — see Permitted attributes.scopetakes an optional label plus a lambda or a block narrowing the relation the policy was built with — see Scopes.contextnames 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 userole, every context keyword and any helper method on the policy class. - The result of an action is coerced with
!!, so returning a record,nilor 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
:contractorextends the shared block with:update, replaces its:listscope and leaves everything else — includingprovider_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::Rolestructs 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
roledeclaration, 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 returnsfalsewhen that role (and:any) does not declare it. - Only declared actions get a predicate; anything else raises
NoMethodErrorrather than quietly answeringfalse. Usecan?for an action name computed at runtime. - A predicate written by hand on the policy class wins over the generated one.
define_action_predicatesis 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
newis given is readable by name —contextdoes not have to be declared. - What the declaration adds is the
nil: a declared name reads asnilwhen the caller leaves it out, so a handler can treat it as optional (order ? … : …above). An undeclared name raisesNameError, so a typo in a handler is not quietly read asnil. - 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 — raisesArgumentError, 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, sorole, 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_codeand 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,:anydeclares one that holds for everyone, and aRolePlays::Mixin::Rolestruct shares one between policies — where a class per role can only share through inheritance from a base, which is why identicalcan_create?bodies get copied across sibling files. - Handlers read descriptive names, not
record.contextlets a policy name the things it talks about —order,orders,period,token_scopes— instead of handing every policy the sameuser/record/optionstriple 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 writesrole: :contractor— no user, no token, no stubbing. - Attributes and scopes gain names. Labels replace one anonymous
permitted_attributesper role and aScopeclass 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 linedef 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.