module Credentials # Specifies an individual 'line' in a Rulebook. # Trivially subclassed as Credentials::Rule::AllowRule # and Credentials::Rule::DenyRule, but this is just # to allow you to create your own, more complex rule types. class Rule attr_accessor :parameters attr_accessor :options def initialize(*args) self.options = args.last.is_a?(Hash) ? args.pop : {} self.parameters = args end # Returns the number of arguments expected in a test to this # rule. This is really basic, but allows a quick first-pass # filtering of the rules. def arity parameters.length end # Returns +true+ if the given arguments match this rule. # Rules mostly use the same matching criteria as Ruby's # +case+ statement: that is, the === operator. # Remember: # User === User.new # a class matches an instance # /\w+/ === "abc" # a RegExp matches a valid string # (1..5) === 3 # a Range matches a number # :foo === :foo # anything matches itself # # There are two exceptions to this behaviour. Firstly, # if the rule specifies an array, then the argument will # match any element of that array: # class User # credentials do |user| # user.can [ :punch, :fight ], [ :shatner, :gandhi ] # end # end # # user.can? :fight, :gandhi # => true # # Secondly, specifying :self in a rule is a nice # shorthand for specifying an object's permissions on itself: # class User # credentials do |user| # user.can :fight, :self # SPOILER ALERT # end # end def match?(*args) values = args.last.is_a?(Hash) ? args.pop : {} return false unless arity == args.length parameters.zip(args).each do |expected, actual| case expected when :self then return false unless actual == args.first when Array then return false unless expected.any? { |item| (item === actual) || (item == :self && actual == args.first) } else return false unless expected == actual || expected === actual end end result = true result = result && (options.keys & Credentials::Prepositions).inject(true) { |memo, key| memo && evaluate_preposition(args.first, options[key], values[key]) } result = result && evaluate_condition(options[:if], :|, *args) unless options[:if].nil? result = result && !evaluate_condition(options[:unless], :&, *args) unless options[:unless].nil? result end # Evaluates an +if+ or +unless+ condition. # [+conditions+] One or more conditions to evaluate. # Can be symbols or procs. # [+op+] Operator used to combine the results # (+|+ for +if+, +&+ for +unless+). # [+args+] List of arguments to test with/against def evaluate_condition(conditions, op, *args) receiver = args.shift args.reject! { |arg| arg.is_a? Symbol } Array(conditions).inject(op == :| ? false : true) do |memo, condition| memo = memo.send op, case condition when Symbol return false unless receiver.respond_to? condition !!receiver.send(condition, *args[0, receiver.method(condition).arity]) when Proc raise ArgumentError, "wrong number of arguments to condition (#{args.size} to #{condition.arity})" unless args.size + 1 == condition.arity !!condition.call(receiver, *args) else raise ArgumentError, "invalid :if or :unless option (expected Symbol or Proc, or array thereof; got #{condition.class})" end end end def evaluate_preposition(object, expected, actual) return true if expected.nil? return false if actual.nil? return true if expected === actual single = expected.to_s plural = single.respond_to?(:pluralize) ? single.pluralize : single + "s" lclass, rclass = [ object.class.name, actual.class.name ].map do |s| s.gsub(/^.*::/, ''). gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). gsub(/([a-z\d])([A-Z])/,'\1_\2'). tr("-", "_"). downcase end if object.respond_to?(:id) && actual.respond_to?(:"#{lclass}_id") return true if actual.send(:"#{lclass}_id").to_i == object.id.to_i end if actual.respond_to?(:id) && object.respond_to?(:"#{rclass}_id") return true if object.send(:"#{rclass}_id").to_i == actual.id.to_i end (object.respond_to?(single) && (object.send(single) == actual)) || (object.respond_to?(plural) && (object.send(plural).include?(actual))) end end end