lib/heimdallr/evaluator.rb in heimdallr-0.0.1 vs lib/heimdallr/evaluator.rb in heimdallr-0.0.2

- old
+ new

@@ -1,85 +1,183 @@ module Heimdallr + # Evaluator is a DSL for managing permissions on records with the field granularity. + # It works by evaluating a block of code within a given <em>security context</em>. + # + # The default resolution is to forbid everything--that is, Heimdallr security policy + # is whitelisting safe actions, not blacklisting unsafe ones. This is by design + # and is not going to change. + # + # The DSL consists of three functions: {#scope}, {#can} and {#cannot}. class Evaluator - attr_reader :whitelist, :validations + attr_reader :allowed_fields, :fixtures, :validators - def initialize(model_class, &block) + # Create a new Evaluator for the ActiveModel-descending class +model_class+, + # and use +block+ to infer restrictions for any security context passed. + def initialize(model_class, block) @model_class, @block = model_class, block - @whitelist = @validations = nil - @last_context = nil + @scopes = {} + @allowed_fields = {} + @validations = {} + @fixtures = {} end - def evaluate(context) - if context != @last_context - @whitelist = Hash.new { [] } - @validations = Hash.new { [] } + # @group DSL - instance_exec context, &block + # Define a scope. A special +:fetch+ scope is applied to any other scope + # automatically. + # + # @overload scope(name, block) + # This form accepts an explicit lambda. + # + # @example + # scope :fetch, -> { where(:protected => false) } + # + # @overload scope(name) + # This form accepts an implicit lambda. + # + # @example + # scope :fetch do + # if user.manager? + # scoped + # else + # where(:invisible => false) + # end + # end + def scope(name, explicit_block, &implicit_block) + @scopes[name] = explicit_block || implicit_block + end - @whitelist.freeze - @validations.freeze + # Define allowed operations for action(s). + # + # The +fields+ parameter accepts both Arrays and Hashes. + # * If an +Array+ is passed, then all fields present in the array are whitelised. + # * If a +Hash+ is passed, then all fields present as hash keys are whitelisted, and: + # 1. If a corresponding value is a +Hash+, it will be processed as a security + # validator. Security validators make records invalid when they are saved through + # a {Proxy::Record}. + # 2. If the corresponding value is any other object, it will be added as a security + # fixture. Fixtures are merged when objects are created through restricted scopes, + # and cause exceptions to be raised when a record is saved, even through the +#save+ + # method. + # + # @example Array of fields + # can :view, [:title, :content] + # + # @example Fixtures + # can :create, { owner: current_user } + # + # @example Validations + # can [:create, :update], { priority: { inclusion: 1..10 } } + # + # @param [Symbol, Array<Symbol>] actions one or more action names + # @param [Hash<Hash, Object>] fields field restrictions + def can(actions, fields=@model_class.attribute_names) + Array(actions).each do |action| + case fields + when Hash # a list of validations + @allowed_fields[action] += fields.keys + @validations[action] += create_validators(fields) + @fixtures[action].merge extract_fixtures(fields) - @last_context = context + else # an array or a field name + @allowed_fields[action] += Array(fields) + end end - - self end - def validate(action, record) - @validations[action].each do |validator| - validator.validate(record) + # Revoke a permission on fields. + # + # @todo Revoke validating restrictions. + # @param [Symbol, Array<Symbol>] actions one or more action names + # @param [Array<Symbol>] fields field list + def cannot(actions, fields) + Array(actions).each do |action| + @allowed_fields[action] -= fields + @fixtures.delete_at *fields end end - def can(actions, fields=@model_class.attribute_names) - actions = Array(actions) + # @endgroup - case fields - when Hash # a list of validations - actions.each do |action| - @whitelist[action] += fields.keys - @validations[action] += make_validators(fields) - end - - else # an array or a field name - actions.each do |action| - @whitelist[action] += Array(fields) - end + # Request a scope. + # + # @param scope name of the scope + # @param basic_scope the scope to which scope +name+ will be applied. Defaults to +:fetch+. + # + # @return ActiveRecord scope + def request_scope(name=:fetch, basic_scope=request_scope(:fetch)) + if name == :fetch || !@scopes.has_key?(name) + fetch_scope = @model_class.instance_exec(&@scopes[:fetch]) + else + basic_scope.instance_exec(&@scopes[name]) end end - def cannot(actions, fields) - actions = Array(actions) + # Compute the restrictions for a given +context+. Invokes a +block+ passed to the + # +initialize+ once. + def evaluate(context) + if context != @last_context + @scopes = {} + @allowed_fields = Hash.new { [] } + @validators = Hash.new { [] } + @fixtures = Hash.new { [] } - actions.each do |action| - @whitelist[action] -= fields + instance_exec context, &block + + [@scopes, @allowed_fields, @validators, @fixtures]. + map(&:freeze) + + @last_context = context end + + self end protected - def make_validators(fields) - validators = [] + # Create validators for +fields+ in +ActiveModel::Validations+-like way. + # + # @return [Array<ActiveModel::Validator>] + def create_validators(fields) + validators = {} fields.each do |attribute, validations| + next unless validations.is_a? Hash + validations.each do |key, options| key = "#{key.to_s.camelize}Validator" begin validator = key.include?('::') ? key.constantize : ActiveModel::Validations.const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end - validators << validator.new(_parse_validates_options(options).merge(:attributes => [ attribute ])) + validators[attribute] = validator.new(_parse_validates_options(options).merge(:attributes => [ attribute ])) end end validators end - def _parse_validates_options(options) #:nodoc: + # Collects fixtures from the +fields+ definition. + def extract_fixtures(fields) + fixtures = {} + + fields.each do |attribute, options| + next if options.is_a? Hash + + fixtures[attribute] = options + end + + fixtures + end + + private + + # Monkey-copied from ActiveRecord. + def _parse_validates_options(options) case options when TrueClass {} when Hash options \ No newline at end of file