# Authorization::Reader module Authorization # Parses an authorization configuration file in the authorization DSL and # constructs a data model of its contents. # # For examples and the modeled data model, see the # README[link:files/README_rdoc.html]. # # Also, see role definition methods # * AuthorizationRulesReader#role, # * AuthorizationRulesReader#includes, # * AuthorizationRulesReader#title, # * AuthorizationRulesReader#description # # Methods for rule definition in roles # * AuthorizationRulesReader#has_permission_on, # * AuthorizationRulesReader#to, # * AuthorizationRulesReader#if_attribute, # * AuthorizationRulesReader#if_permitted_to # # Methods to be used in if_attribute statements # * AuthorizationRulesReader#contains, # * AuthorizationRulesReader#does_not_contain, # * AuthorizationRulesReader#intersects_with, # * AuthorizationRulesReader#is, # * AuthorizationRulesReader#is_not, # * AuthorizationRulesReader#is_in, # * AuthorizationRulesReader#is_not_in, # * AuthorizationRulesReader#lt, # * AuthorizationRulesReader#lte, # * AuthorizationRulesReader#gt, # * AuthorizationRulesReader#gte # * AuthorizationRulesReader#id_in_scope # # And privilege definition methods # * PrivilegesReader#privilege, # * PrivilegesReader#includes # module Reader # Signals that the specified file to load was not found. class DSLFileNotFoundError < Exception; end # Signals errors that occur while reading and parsing an authorization DSL class DSLError < Exception; end # Signals errors in the syntax of an authorization DSL. class DSLSyntaxError < DSLError; end # Top-level reader, parses the methods +privileges+ and +authorization+. # +authorization+ takes a block with authorization rules as described in # AuthorizationRulesReader. The block to +privileges+ defines privilege # hierarchies, as described in PrivilegesReader. # class DSLReader attr_reader :privileges_reader, :auth_rules_reader # :nodoc: def initialize() @privileges_reader = PrivilegesReader.new @auth_rules_reader = AuthorizationRulesReader.new end def initialize_copy(from) # :nodoc: @privileges_reader = from.privileges_reader.clone @auth_rules_reader = from.auth_rules_reader.clone end # ensures you get back a DSLReader # if you provide a: # DSLReader - you will get it back. # String or Array - it will treat it as if you have passed a path or an array of paths and attempt to load those. def self.factory(obj) case obj when Reader::DSLReader obj when String, Array load(obj) end end # Parses a authorization DSL specification from the string given # in +dsl_data+. Raises DSLSyntaxError if errors occur on parsing. def parse(dsl_data, file_name = nil) if file_name DSLMethods.new(self).instance_eval(dsl_data, file_name) else DSLMethods.new(self).instance_eval(dsl_data) end rescue SyntaxError, NoMethodError, NameError => e raise DSLSyntaxError, "Illegal DSL syntax: #{e}" end # Load and parse a DSL from the given file name. def load(dsl_file) parse(File.read(dsl_file), dsl_file) if File.exist?(dsl_file) end # Load and parse a DSL from the given file name. Raises Authorization::Reader::DSLFileNotFoundError # if the file cannot be found. def load!(dsl_file) raise ::Authorization::Reader::DSLFileNotFoundError, "Error reading authorization rules file with path '#{dsl_file}'! Please ensure it exists and that it is accessible." unless File.exist?(dsl_file) load(dsl_file) end # Loads and parses DSL files and returns a new reader def self.load(dsl_files) # TODO cache reader in production mode? reader = new dsl_files = [dsl_files].flatten dsl_files.each do |file| reader.load(file) end reader end # DSL methods class DSLMethods # :nodoc: def initialize(parent) @parent = parent end def privileges(&block) @parent.privileges_reader.instance_eval(&block) end def contexts(&block) # Not implemented end def authorization(&block) @parent.auth_rules_reader.instance_eval(&block) end end end # The PrivilegeReader handles the part of the authorization DSL in # a +privileges+ block. Here, privilege hierarchies are defined. class PrivilegesReader # TODO handle privileges with separated context attr_reader :privileges, :privilege_hierarchy # :nodoc: def initialize # :nodoc: @current_priv = nil @current_context = nil @privileges = [] # {priv => [[priv,ctx], ...]} @privilege_hierarchy = {} end def initialize_copy(from) # :nodoc: @privileges = from.privileges.clone @privilege_hierarchy = from.privilege_hierarchy.clone end def append_privilege(priv) # :nodoc: @privileges << priv unless @privileges.include?(priv) end # Defines part of a privilege hierarchy. For the given +privilege+, # included privileges may be defined in the block (through includes) # or as option :+includes+. If the optional context is given, # the privilege hierarchy is limited to that context. # def privilege(privilege, context = nil, options = {}, &block) if context.is_a?(Hash) options = context context = nil end @current_priv = privilege @current_context = context append_privilege privilege instance_eval(&block) if block includes(*options[:includes]) if options[:includes] ensure @current_priv = nil @current_context = nil end # Specifies +privileges+ that are to be assigned as lower ones. Only to # be used inside a privilege block. def includes(*privileges) raise DSLError, "includes only in privilege block" if @current_priv.nil? privileges.each do |priv| priv = priv.to_sym append_privilege priv @privilege_hierarchy[@current_priv] ||= [] @privilege_hierarchy[@current_priv] << [priv, @current_context] end end end class AuthorizationRulesReader attr_reader :roles, :role_hierarchy, :auth_rules, :role_descriptions, :role_titles, :omnipotent_roles # :nodoc: def initialize # :nodoc: @current_role = nil @current_rule = nil @roles = [] @omnipotent_roles = [] # higher_role => [lower_roles] @role_hierarchy = {} @role_titles = {} @role_descriptions = {} @auth_rules = AuthorizationRuleSet.new end def initialize_copy(from) # :nodoc: [:roles, :role_hierarchy, :auth_rules, :role_descriptions, :role_titles, :omnipotent_roles].each do |attribute| instance_variable_set(:"@#{attribute}", from.send(attribute).clone) end end def append_role(role, options = {}) # :nodoc: @roles << role unless @role_titles.key? role @role_titles[role] = options[:title] if options[:title] @role_descriptions[role] = options[:description] if options[:description] end # Defines the authorization rules for the given +role+ in the # following block. # role :admin do # has_permissions_on ... # end # def role(role, options = {}) append_role role, options @current_role = role yield ensure @current_role = nil end # Roles may inherit all the rights from subroles. The given +roles+ # become subroles of the current block's role. # role :admin do # includes :user # has_permission_on :employees, :to => [:update, :create] # end # role :user do # has_permission_on :employees, :to => :read # end # def includes(*roles) raise DSLError, "includes only in role blocks" if @current_role.nil? @role_hierarchy[@current_role] ||= [] @role_hierarchy[@current_role] += roles.flatten end # Allows the definition of privileges to be allowed for the current role, # either in a has_permission_on block or directly in one call. # role :admin # has_permission_on :employees, :to => :read # has_permission_on [:employees, :orders], :to => :read # has_permission_on :employees do # to :create # if_attribute ... # end # has_permission_on :employees, :to => :delete do # if_attribute ... # end # end # The block form allows to describe restrictions on the permissions # using if_attribute. Multiple has_permission_on statements are # OR'ed when evaluating the permissions. Also, multiple if_attribute # statements in one block are OR'ed if no :+join_by+ option is given # (see below). To AND conditions, either set :+join_by+ to :and or place # them in one if_attribute statement. # # Available options # [:+to+] # A symbol or an array of symbols representing the privileges that # should be granted in this statement. # [:+join_by+] # Join operator to logically connect the constraint statements inside # of the has_permission_on block. May be :+and+ or :+or+. Defaults to :+or+. # def has_permission_on(*args) options = args.extract_options! context = args.flatten raise DSLError, "has_permission_on only allowed in role blocks" if @current_role.nil? options = {:to => [], :join_by => :or}.merge(options) privs = options[:to] privs = [privs] unless privs.is_a?(Array) raise DSLError, "has_permission_on either needs a block or :to option" if !block_given? and privs.empty? file, line = file_and_line_number_from_call_stack rule = AuthorizationRule.new(@current_role, privs.map(&:to_sym), context, options[:join_by], :source_file => file, :source_line => line) @auth_rules << rule if block_given? @current_rule = rule yield raise DSLError, "has_permission_on block content specifies no privileges" if rule.privileges.empty? # TODO ensure? @current_rule = nil end end # Removes any permission checks for the current role. # role :admin # has_omnipotence # end def has_omnipotence raise DSLError, "has_omnipotence only allowed in role blocks" if @current_role.nil? @omnipotent_roles << @current_role end # Sets a description for the current role. E.g. # role :admin # description "To be assigned to administrative personnel" # has_permission_on ... # end def description(text) raise DSLError, "description only allowed in role blocks" if @current_role.nil? role_descriptions[@current_role] = text end # Sets a human-readable title for the current role. E.g. # role :admin # title "Administrator" # has_permission_on ... # end def title(text) raise DSLError, "title only allowed in role blocks" if @current_role.nil? role_titles[@current_role] = text end # Used in a has_permission_on block, to may be used to specify privileges # to be assigned to the current role under the conditions specified in # the current block. # role :admin # has_permission_on :employees do # to :create, :read, :update, :delete # end # end def to(*privs) raise DSLError, "to only allowed in has_permission_on blocks" if @current_rule.nil? @current_rule.append_privileges(privs.flatten) end # In a has_permission_on block, if_attribute specifies conditions # of dynamic parameters that have to be met for the user to meet the # privileges in this block. Conditions are evaluated on the context # object. Thus, the following allows CRUD for branch admins only on # employees that belong to the same branch as the current user. # role :branch_admin # has_permission_on :employees do # to :create, :read, :update, :delete # if_attribute :branch => is { user.branch } # end # end # In this case, is is the operator for evaluating the condition. Another # operator is contains for collections. In the block supplied to the # operator, +user+ specifies the current user for whom the condition # is evaluated. # # Conditions may be nested: # role :company_admin # has_permission_on :employees do # to :create, :read, :update, :delete # if_attribute :branch => { :company => is {user.branch.company} } # end # end # # has_many and has_many through associations may also be nested. # Then, at least one item in the association needs to fulfill the # subsequent condition: # if_attribute :company => { :branches => { :manager => { :last_name => is { user.last_name } } } # Beware of possible performance issues when using has_many associations in # permitted_to? checks. For # permitted_to? :read, object # a check like # object.company.branches.any? { |branch| branch.manager ... } # will be executed. with_permission_to scopes construct efficient SQL # joins, though. # # Multiple attributes in one :if_attribute statement are AND'ed. # Multiple if_attribute statements are OR'ed if the join operator for the # has_permission_on block isn't explicitly set. Thus, the following would # require the current user either to be of the same branch AND the employee # to be "changeable_by_coworker". OR the current user has to be the # employee in question. # has_permission_on :employees, :to => :manage do # if_attribute :branch => is {user.branch}, :changeable_by_coworker => true # if_attribute :id => is {user.id} # end # The join operator for if_attribute rules can explicitly set to AND, though. # See has_permission_on for details. # # Arrays and fixed values may be used directly as hash values: # if_attribute :id => 1 # if_attribute :type => "special" # if_attribute :id => [1,2] # def if_attribute(attr_conditions_hash) raise DSLError, "if_attribute only in has_permission blocks" if @current_rule.nil? parse_attribute_conditions_hash!(attr_conditions_hash) @current_rule.append_attribute Attribute.new(attr_conditions_hash) end # if_permitted_to allows the has_permission_on block to depend on # permissions on associated objects. By using it, the authorization # rules may be a lot DRYer. E.g.: # # role :branch_manager # has_permission_on :branches, :to => :manage do # if_attribute :employees => contains { user } # end # has_permission_on :employees, :to => :read do # if_permitted_to :read, :branch # # instead of # # if_attribute :branch => { :employees => contains { user } } # end # end # # if_permitted_to associations may be nested as well: # if_permitted_to :read, :branch => :company # # You can even use has_many associations as target. Then, it is checked # if the current user has the required privilege on *any* of the target objects. # if_permitted_to :read, :branch => :employees # Beware of performance issues with permission checks. In the current implementation, # all employees are checked until the first permitted is found. # with_permissions_to, on the other hand, constructs more efficient SQL # instead. # # To check permissions based on the current object, the attribute has to # be left out: # has_permission_on :branches, :to => :manage do # if_attribute :employees => contains { user } # end # has_permission_on :branches, :to => :paint_green do # if_permitted_to :update # end # Normally, one would merge those rules into one. Dividing makes sense # if additional if_attribute are used in the second rule or those rules # are applied to different roles. # # Options: # [:+context+] # When using with_permissions_to, the target context of the if_permitted_to # statement is inferred from the last reflections target class. Still, # you may override this algorithm by setting the context explicitly. # if_permitted_to :read, :home_branch, :context => :branches # if_permitted_to :read, :branch => :main_company, :context => :companies # def if_permitted_to(privilege, attr_or_hash = nil, options = {}) raise DSLError, "if_permitted_to only in has_permission blocks" if @current_rule.nil? options[:context] ||= attr_or_hash.delete(:context) if attr_or_hash.is_a?(Hash) # only :context option in attr_or_hash: attr_or_hash = nil if attr_or_hash.is_a?(Hash) and attr_or_hash.empty? @current_rule.append_attribute AttributeWithPermission.new(privilege, attr_or_hash, options[:context]) end # In an if_attribute statement, is says that the value has to be # met exactly by the if_attribute attribute. For information on the block # argument, see if_attribute. def is(&block) [:is, block] end # The negation of is. def is_not(&block) [:is_not, block] end # In an if_attribute statement, contains says that the value has to be # part of the collection specified by the if_attribute attribute. # For information on the block argument, see if_attribute. def contains(&block) [:contains, block] end # The negation of contains. Currently, query rewriting is disabled # for does_not_contain. def does_not_contain(&block) [:does_not_contain, block] end # In an if_attribute statement, intersects_with requires that at least # one of the values has to be part of the collection specified by the # if_attribute attribute. The value block needs to evaluate to an # Enumerable. For information on the block argument, see if_attribute. def intersects_with(&block) [:intersects_with, block] end # In an if_attribute statement, is_in says that the value has to # contain the attribute value. # For information on the block argument, see if_attribute. def is_in(&block) [:is_in, block] end # The negation of is_in. def is_not_in(&block) [:is_not_in, block] end # Less than def lt(&block) [:lt, block] end # Less than or equal to def lte(&block) [:lte, block] end # Greater than def gt(&block) [:gt, block] end # Greater than or equal to def gte(&block) [:gte, block] end def id_in_scope(&block) [:id_in_scope, block] end private def parse_attribute_conditions_hash!(hash) merge_hash = {} hash.each do |key, value| if value.is_a?(Hash) parse_attribute_conditions_hash!(value) elsif !value.is_a?(Array) merge_hash[key] = [:is, value ] elsif value.is_a?(Array) and !value[0].is_a?(Symbol) merge_hash[key] = [:is_in, value ] end end hash.merge!(merge_hash) end def file_and_line_number_from_call_stack caller_parts = caller(2).first.split(':') [caller_parts[0] == "(eval)" ? nil : caller_parts[0], caller_parts[1] && caller_parts[1].to_i] end end end end