Skip to content

Instantly share code, notes, and snippets.

@printercu
Last active March 5, 2018 11:43
Show Gist options
  • Save printercu/c47ae9c8a30fcbe810744242e52cc604 to your computer and use it in GitHub Desktop.
Save printercu/c47ae9c8a30fcbe810744242e52cc604 to your computer and use it in GitHub Desktop.
Policy like in cancan, but with class-level declarations
# Simple policy implementation. Like a mixin of Pundit and CanCan.
module Policy
ACTIONS_MAP = {
index: :read,
show: :read,
new: :create,
edit: :update,
}.freeze
class Error < StandardError; end
# Error that will be raised when authorization has failed
class NotAuthorized < Error
attr_reader :options
def initialize(options = {})
message =
if options.is_a? String
options
else
@options = options
options.fetch(:message) { 'You are not allowed to perform this action' }
end
super(message)
end
end
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
attr_reader :rules
# `:if` option is just short
def add_rule(result, actions, objects = nil, **options, &block)
blocks = [options[:if], block].compact
actions = Array.wrap(actions).map { |x| map_action(x) }.uniq
objects = Array.wrap(objects)
@rules ||= []
rules << Rule.new(result, actions, objects, blocks)
end
def can(*args, &block)
add_rule(true, *args, &block)
end
def cannot(*args, &block)
add_rule(false, *args, &block)
end
def can?(action, object, context)
action = map_action(action)
rules.find { |rule| rule.match?(action, object, context) }&.result || false
end
def map_action(action)
ACTIONS_MAP[action] || action
end
end
class Rule
attr_reader :result, :actions, :objects, :blocks
def initialize(result, actions, objects, blocks)
@result = result
@actions = actions.include?(:manage) ? :manage : actions
objects = [nil] if objects.empty?
@objects = objects.include?(:all) ? :all : objects
@blocks = blocks
end
def match?(action, object, context)
return unless actions == :manage || actions.include?(action)
return unless objects == :all || objects.
any? { |x| x === object } # rubocop:disable CaseEquality
blocks.all? { |x| context.instance_exec(object, &x) }
end
end
def can?(action, object = nil)
self.class.can?(action, object, self)
end
def authorize!(action, object = nil)
return if can?(action, object)
raise NotAuthorized, policy: self, action: action, object: object
end
module ControllerHelpers
extend ActiveSupport::Concern
module ClassMethods
# In this way it does not inherit default_policy_class,
# but inherit policy_class. If policy is not set expplicitly,
# it uses class name to find it.
def policy_class_with_default
@_policy_class_with_default ||= policy_class ||
Object.const_get(name.to_s.demodulize.sub(/Controller$/, 'Policy').singularize)
end
end
included do
helper_method :current_policy, :can?
class_attribute :policy_class, instance_accessor: false
end
protected
def current_policy
@_current_policy ||= self.class.policy_class_with_default.new(current_user)
end
delegate :authorize!, :can?, to: :current_policy
end
end
RSpec.describe Policy do
let(:klass) { Struct.new(:context).tap { |x| x.send :include, described_class } }
let(:instance) { klass.new(context) }
let(:context) {}
describe '#can?' do
subject { ->(*args) { instance.can?(*args) } }
before do
klass.can :read, Symbol
klass.can :edit, Numeric
end
it 'returns true for matching action and object' do
expect(subject[:read, :sym]).to eq true
expect(subject[:edit, :sym]).to eq false
expect(subject[:read, 1]).to eq false
expect(subject[:edit, 1]).to eq true
end
it 'respects aliased actions' do
expect(subject[:show, :sym]).to eq true
expect(subject[:index, :sym]).to eq true
expect(subject[:edit, :sym]).to eq false
expect(subject[:update, 1]).to eq true
expect(subject[:read, 1]).to eq false
end
it 'works for nested classes' do
expect(1.class).to_not eq Numeric
expect(subject[:update, 1]).to eq true
expect(subject[:read, Integer]).to eq false
end
context 'with can :manage' do
before { klass.can :manage, Symbol }
it 'returns true for any action' do
expect(subject[:read, :sym]).to eq true
expect(subject[:smth, :sym]).to eq true
expect(subject[:read, 1]).to eq false
end
end
context 'with can ..., :all' do
before { klass.can :destroy, :all }
it 'returns true for any object' do
expect(subject[:destroy, :sym]).to eq true
expect(subject[:destroy, 'str']).to eq true
expect(subject[:read, 'str']).to eq false
end
end
context 'for custom objects do' do
before { klass.can :watch, :dashboard }
it 'returns true when input matches' do
expect(subject[:watch, :dashboard]).to eq true
expect(subject[:watch, :sym]).to eq false
expect(subject[:update, :dashboard]).to eq false
end
end
shared_examples 'checks conditions' do
let(:context) { {} }
it 'returns true if condition passes' do
expect(subject[:read, :sym]).to eq true
expect(subject[:read, 1]).to eq false
expect { context[:valid] = true }.
to change { subject[:read, 's'] }.from(false).to(true).
and not_change { subject[:read, 'str'] }.from(false)
end
end
context 'with cannot' do
before do
klass.cannot :edit, String
klass.can :manage, String
klass.cannot :manage, String
end
it 'returns false if it matches' do
expect(subject[:edit, 'str']).to eq false
expect(subject[:smth, 'str']).to eq true
expect(subject[:edit, 1]).to eq true
end
end
context 'with block' do
before { klass.can(:read, String) { |x| context[:valid] && x.size == 1 } }
include_examples 'checks conditions'
end
context 'with if: option' do
before { klass.can :read, String, if: ->(x) { context[:valid] && x.size == 1 } }
include_examples 'checks conditions'
end
context 'with block and if:' do
before { klass.can(:read, String, if: ->(x) { x.size == 1 }) { context[:valid] } }
include_examples 'checks conditions'
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment