Last active
March 5, 2018 11:43
-
-
Save printercu/c47ae9c8a30fcbe810744242e52cc604 to your computer and use it in GitHub Desktop.
Policy like in cancan, but with class-level declarations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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