# Module, welches AccesRules fuer Controller/Actions und # Model-Object umsetzt. # # Die Regeln werden aus der Datei "config/acces_rules.rb" geladen # # Author: Bernd Ledig # require 'singleton' require 'logger' require 'thread' module Tuersteher # Logger to log messages with timestamp and severity class TLogger < Logger @@logger = nil def format_message(severity, timestamp, progname, msg) "#{timestamp.to_formatted_s(:db)} #{severity} #{msg}\n" end def self.logger return @@logger if @@logger @@logger = self.new(File.join(Rails.root, 'log', 'tuersteher.log'), 3) @@logger.level = INFO if Rails.env != 'development' @@logger end def self.logger= logger @@logger = logger end end class AccessRulesStorage include Singleton attr_writer :rules_config_file # to set own access_rules-path attr_accessor :check_intervall # check intervall in seconds to check config file attr_accessor :path_prefix # prefix for path-rules DEFAULT_RULES_CONFIG_FILE = 'access_rules.rb' # in config-dir # private initializer why this class is a singleton def initialize @path_rules = [] @model_rules = [] @check_intervall = 300 # set default check interval to 5 minutes @mutex = Mutex.new end def ready? @was_read end # get all path_rules as array of PathAccessRule-Instances def path_rules read_rules_if_needed @path_rules end # get all model_rules as array of ModelAccessRule-Instances def model_rules read_rules_if_needed @model_rules end def read_rules_if_needed if @was_read # im check_intervall pruefen ob AccessRules-File sich geändert hat t = Time.now.to_i @last_read_check ||= t if (t - @last_read_check) > @check_intervall @last_read_check = t cur_mtime = File.mtime(self.rules_config_file) @last_mtime ||= cur_mtime if cur_mtime > @last_mtime @last_mtime = cur_mtime read_rules end end else read_rules end end def rules_config_file @rules_config_file ||= File.join(Rails.root, 'config', DEFAULT_RULES_CONFIG_FILE) end # evaluated rules_definitions and create path-/model-rules def eval_rules rules_definitions @path_rules = [] @model_rules = [] eval rules_definitions, binding, (@rules_config_file||'no file') @was_read = true Tuersteher::TLogger.logger.info "Tuersteher::AccessRulesStorage: #{@path_rules.size} path-rules and #{@model_rules.size} model-rules loaded" extend_path_rules_with_prefix end # Load AccesRules from file # config/access_rules.rb def read_rules @was_read = false @mutex.synchronize do return if @was_read # dann hat ein anderer Thread bereits gelesen content = File.read self.rules_config_file if content eval_rules content end end rescue => ex Tuersteher::TLogger.logger.error "Tuersteher::AccessRulesStorage - Error in rules: #{ex.message}\n\t"+ex.backtrace.join("\n\t") end # definiert HTTP-Pfad-basierende Zugriffsregel # # path: :all fuer beliebig, sonst String mit der http-path beginnen muss, # wird als RegEX-Ausdruck ausgewertet def path url_path if block_given? @current_rule_class = PathAccessRule @current_rule_init = url_path @current_rule_storage = @path_rules yield @current_rule_class = @current_rule_init = nil else rule = PathAccessRule.new(url_path) @path_rules << rule rule end end # definiert Model-basierende Zugriffsregel # # model_class: Model-Klassenname(als CLass oder String) oder :all fuer alle def model model_class if block_given? @current_rule_class = ModelAccessRule @current_rule_init = model_class @current_rule_storage = @model_rules yield @current_rule_class = @current_rule_init = @current_rule_storage = nil else rule = ModelAccessRule.new(model_class) @model_rules << rule rule end end # create new rule as grant-rule # and add this to the model_rules array def grant rule = @current_rule_class.new(@current_rule_init) @current_rule_storage << rule rule.grant end # create new rule as deny-rule # and add this to the model_rules array def deny rule = grant rule.deny end private # Erweitern des Path um einen Prefix # Ist notwenig wenn z.B. die Rails-Anwendung nicht als root-Anwendung läuft # also root_path != '/' ist.' def extend_path_rules_with_prefix return if @path_prefix.nil? || @path_rules.nil? prefix = @path_prefix.chomp('/') # das abschliessende / entfernen @path_rules.each do |rule| path_spec = rule.path_spezification if path_spec path_spec.path = "#{prefix}#{path_spec.path}" end end Tuersteher::TLogger.logger.info "extend_path_rules_with_prefix: #{prefix}" end end # of AccessRulesStorage class AccessRules class << self # Pruefen Zugriff fuer eine Web-action # @param login_context Login-Contex, für den der Zugriff geprüft werden soll (muss Methode has_role? haben) # @param path Pfad der Webresource (String) # @param method http-Methode (:get, :put, :delete, :post), default ist :get # def path_access?(login_context, path, method = :get) rule = AccessRulesStorage.instance.path_rules.detect do |r| r.fired?(path, method, login_context) end if Tuersteher::TLogger.logger.debug? if rule.nil? s = 'denied' else s = "fired with #{rule}" end lc_id = login_context && login_context.respond_to?(:id) ? login_context.id : login_context.object_id Tuersteher::TLogger.logger.debug("Tuersteher: path_access?(login_context.id=#{lc_id}, path=#{path}, method=#{method}) => #{s}") end !(rule.nil? || rule.deny?) end # Pruefen Zugriff auf ein Model-Object # # @param login_context Login-Contex, für den der Zugriff geprüft werden soll (muss Methode has_role? haben) # @param model das Model-Object # @param permission das geforderte Zugriffsrecht (:create, :update, :destroy, :get) # # liefert true/false def model_access? login_context, model, permission raise "Wrong call! Use: model_access(model-instance-or-class, permission)" unless permission.is_a? Symbol return false unless model rule = AccessRulesStorage.instance.model_rules.detect do |rule| rule.fired? model, permission, login_context end access = rule && !rule.deny? if Tuersteher::TLogger.logger.debug? lc_id = login_context && login_context.respond_to?(:id) ? login_context.id : login_context.object_id if model.instance_of?(Class) Tuersteher::TLogger.logger.debug( "Tuersteher: model_access?(login_context.id=#{lc_id}, model=#{model}, permission=#{permission}) => #{access || 'denied'} #{rule}") else Tuersteher::TLogger.logger.debug( "Tuersteher: model_access?(login_context.id=#{lc_id}, model=#{model.class}(#{model.respond_to?(:id) ? model.id : model.object_id }), permission=#{permission}) => #{access || 'denied'} #{rule}") end end access end # Bereinigen (entfernen) aller Objecte aus der angebenen Collection, # wo der angegebene login_context nicht das angegebene Recht hat # # liefert ein neues Array mit den Objecten, wo der spez. Zugriff arlaubt ist def purge_collection login_context, collection, permission collection.select{|model| model_access?(login_context, model, permission)} end end # of Class-Methods end # of AccessRules # Module zum Include in Controllers # Dieser muss die folgenden Methoden bereitstellen: # # login_context : akt. Login-Contex # access_denied : Methode aus dem authenticated_system, welche ein redirect zum login auslöst # # Der Loginlogin_contex muss fuer die hier benoetigte Funktionalitaet # die Methode: # has_role?(role) # role the Name of the Role as Symbol # besitzen. # # Beispiel der Einbindung in den ApplicationController # include Tuersteher::ControllerExtensions # before_filter :check_access # methode is from Tuersteher::ControllerExtensions # module ControllerExtensions # Pruefen Zugriff fuer eine Web-action # # path Pfad der Webresource (String) # method http-Methode (:get, :put, :delete, :post), default ist :get # def path_access?(path, method = :get) AccessRules.path_access? login_context, path, method end # Pruefen Zugriff auf ein Model-Object # # model das Model-Object # permission das geforderte Zugriffsrecht (:create, :update, :destroy, :get) # # liefert true/false def model_access? model, permission AccessRules.model_access? login_context, model, permission end # Bereinigen (entfernen) aller Objecte aus der angebenen Collection, # wo der akt. login_context nicht das angegebene Recht hat # # liefert ein neues Array mit den Objecten, wo der spez. Zugriff arlaubt ist def purge_collection collection, permission AccessRules.purge_collection(login_context, collection, permission) end def self.included(base) base.class_eval do # Diese Methoden auch als Helper fuer die Views bereitstellen if(self.respond_to?(:helper_method)) helper_method :path_access?, :model_access?, :purge_collection end end end protected # Pruefen, ob Zugriff des login_context # fuer aktullen Request erlaubt ist def check_access ar_storage = AccessRulesStorage.instance unless ar_storage.ready? # bei nicht production-env check-intervall auf 5 sek setzen ar_storage.check_intervall = 5 if Rails.env!='production' # set root-path as prefix for all path rules prefix = respond_to?(:root_path) && root_path ar_storage.path_prefix = prefix if prefix && prefix.size > 1 ar_storage.read_rules end # bind login_context on the current thread Thread.current[:login_context] = login_context req_method = request.method req_method = req_method.downcase.to_sym if req_method.is_a?(String) url_path = request.fullpath unless path_access?(url_path, req_method) lc_id = login_context && (login_context.respond_to?(:id) ? login_context.id : login_context.object_id) msg = "Tuersteher#check_access: access denied for #{url_path} :#{req_method} login_context.id=#{lc_id}" Tuersteher::TLogger.logger.warn msg logger.warn msg # log message also for Rails-Default logger access_denied # Methode aus dem authenticated_system, welche z.B. ein redirect zum login auslöst end end end # Module for include in Model-Object-Classes # # The module get the login_context from Thread.current[:login_context] # # Sample for ActiveRecord-Class # class Sample < ActiveRecord::Base # include Tuersteher::ModelExtensions # # def transfer_to account # check_model_access :transfer # raise a exception if not allowed # .... # end # # module ModelExtensions # Check permission for the Model-Object # # permission the requested permission (sample :create, :update, :destroy, :get) # # raise a SecurityError-Exception if access denied def check_access permission login_context = Thread.current[:login_context] unless AccessRules.model_access? login_context, self, permission raise SecurityError, "Access denied! Current login_context have no permission '#{permission}' on Model-Object #{self}." end end def self.included(base) base.extend ClassMethods end module ClassMethods # Bereinigen (entfernen) aller Objecte aus der angebenen Collection, # wo der akt. login_context nicht das angegebene Recht hat # # liefert ein neues Array mit den Objecten, wo der spez. Zugriff arlaubt ist def purge_collection collection, permission login_context = Thread.current[:login_context] AccessRules.purge_collection(login_context, collection, permission) end end # of ClassMethods end # of module ModelExtensions # The Classes for the separate Rule-Specifications class PathSpecification attr_reader :path def initialize path, negation @negation = negation self.path = path end def path= url_path @path = url_path # url_path in regex ^#{path} wandeln ausser bei "/", # dies darf keine Regex mit ^/ werden, da diese dann ja immer matchen wuerde if url_path == "/" @path_regex = /^\/$/ else @path_regex = /^#{url_path}/ end end def grant? path_or_model, method, login_ctx rc = @path_regex =~ path_or_model rc = !rc if @negation rc end def to_s "#{@negation && 'not.'}path('#{@path}')" end end class ModelSpecification def initialize clazz, negation clazz = clazz.name if clazz.is_a?(Class) @clazz, @negation = clazz, negation end def grant? path_or_model, method, login_ctx m_class = path_or_model.instance_of?(Class) ? path_or_model.name : path_or_model.class.name rc = @clazz == m_class rc = !rc if @negation rc end def to_s "#{@negation && 'not.'}model(#{@clazz})" end end class RolesSpecification attr_reader :roles, :negation def initialize role, negation @negation = negation @roles = [role] end def grant? path_or_model, method, login_ctx return false if login_ctx.nil? # roles sind or verknüpft rc = @roles.any?{|role| login_ctx.has_role?(role) } rc = !rc if @negation rc end def to_s role_s = @roles.size == 1 ? "role(:#{@roles.first})" : "roles(#{@roles.map{|r| ":#{r}"}.join(',')})" "#{@negation && 'not.'}#{role_s}" end end class RightSpecification attr_reader :rights, :negation def initialize right, negation @negation = negation @rights = [right] end def grant? path_or_model, method, login_ctx return false if login_ctx.nil? rc =@rights.any?{|right| login_ctx.has_right?(right) } rc = !rc if @negation rc end def to_s "#{@negation && 'not.'}rights(#{@rights.join(',')})" end end class MethodSpecification def initialize method, negation @method, @negation = method, negation end def grant? path_or_model, method, login_ctx rc = @method==method rc = !rc if @negation rc end def to_s "#{@negation && 'not.'}method(:#{@method})" end end class ExtensionSpecification def initialize method_name, negation, expected_value=nil @method, @negation, @expected_value = method_name, negation, expected_value end def grant? path_or_model, method, login_ctx rc = false if path_or_model.is_a?(String) # path-variante return false if login_ctx.nil? unless login_ctx.respond_to?(@method) Tuersteher::TLogger.logger.warn("#{to_s}.grant? => false why Login-Context have not method '#{@method}'!") return false end if @expected_value rc = login_ctx.send(@method, @expected_value) else rc = login_ctx.send(@method) end else # model-variante unless path_or_model.respond_to?(@method) m_msg = path_or_model.instance_of?(Class) ? "Class '#{path_or_model.name}'" : "Object '#{path_or_model.class}'" Tuersteher::TLogger.logger.warn("#{to_s}.grant? => false why #{m_msg} have not method '#{@method}'!") return false end if @expected_value rc = path_or_model.send(@method, login_ctx, @expected_value) else rc = path_or_model.send(@method, login_ctx) end end rc = !rc if @negation rc end def to_s val_s = @expected_value.nil? ? nil : ", #{@expected_value}" "#{@negation && 'not.'}extension(:#{@method}#{val_s})" end end # Abstracte base class for Access-Rules class BaseAccessRule attr_reader :rule_spezifications def initialize @rule_spezifications = [] @last_role_specification @last_right_specification end # add right def right(right_name) return self if right_name==:all # :all is only syntax sugar raise "wrong right '#{right_name}'! Must be a symbol " unless right_name.is_a?(Symbol) # rights are OR-linked (per default) # => add the right to RightSpecification, create only new RightSpecification if not exist if @last_right_specification raise("Mixin of right and not.right are yet not implemented!") if @negation != @last_right_specification.negation @last_right_specification.rights << right_name else @last_right_specification = RightSpecification.new(right_name, @negation) @rule_spezifications << @last_right_specification end @negation = false if @negation self end # add role def role(role_name) return self if role_name==:all # :all is only syntax sugar raise "wrong role '#{role_name}'! Must be a symbol " unless role_name.is_a?(Symbol) # roles are OR-linked (per default) # => add the role to RolesSpecification, create only new RolesSpecification if not exist if @last_role_specification raise("Mixin of role and not.role are yet not implemented!") if @negation != @last_role_specification.negation @last_role_specification.roles << role_name else @last_role_specification = RolesSpecification.new(role_name, @negation) @rule_spezifications << @last_role_specification end @negation = false if @negation self end # add list of roles def roles(*role_names) negation_state = @negation role_names.flatten.each do |role_name| self.role(role_name) @negation = negation_state # keep Negation-State for all roles end @negation = false if @negation self end # add extension-definition # parmaters: # method_name: Symbol with the name of the method to call for addional check # expected_value: optional expected value for the result of the with metho_name specified method, defalt is true def extension method_name, expected_value=nil @rule_spezifications << ExtensionSpecification.new(method_name, @negation, expected_value) @negation = false if @negation self end # set methode for access # access_method Name of Methode for access as Symbol def method(access_method) return self if access_method==:all # :all is only syntax sugar @rule_spezifications << MethodSpecification.new(access_method, @negation) @negation = false if @negation self end # mark this rule as grant-rule def grant self end # mark this rule as deny-rule def deny @deny = true self end # is this rule a deny-rule def deny? @deny end # negate role followed rule specification (role or extension def not @negation = true self end # check, if this rule fired for specified parameter def fired? path_or_model, method, login_ctx login_ctx = nil if login_ctx==:false # manche Authenticate-System setzen den login_ctx/login_context auf :false @rule_spezifications.all?{|spec| spec.grant?(path_or_model, method, login_ctx)} end def to_s "Rule[#{@deny ? 'deny' : 'grant'}.#{@rule_spezifications.map(&:to_s).join('.')}]" end end # of BaseAccessRule class PathAccessRule < BaseAccessRule METHOD_NAMES = [:get, :edit, :put, :delete, :post, :all].freeze attr_reader :path_spezification # Zugriffsregel # # path :all fuer beliebig, sonst String mit der http-path beginnen muss # def initialize(path) raise "wrong path '#{path}'! Must be a String or :all ." unless path==:all or path.is_a?(String) super() if path != :all # :all is only syntax sugar @path_spezification = PathSpecification.new(path, @negation) @rule_spezifications << @path_spezification end end # set http-methode # http_method http-Method, allowed is :get, :put, :delete, :post, :all def method(http_method) raise "wrong method '#{http_method}'! Must be #{METHOD_NAMES.join(', ')} !" unless METHOD_NAMES.include?(http_method) super self end def to_s @_to_s ||= super end end class ModelAccessRule < BaseAccessRule # erzeugt neue Object-Zugriffsregel # # clazz Model-Klassenname(als Class oder String) oder :all fuer alle # def initialize(clazz) raise "wrong clazz '#{clazz}'! Must be a Class/String or :all ." unless clazz==:all or clazz.is_a?(Class) or clazz.is_a?(String) super() if clazz != :all # :all is only syntax sugar @rule_spezifications << ModelSpecification.new(clazz, @negation) end end def to_s @_to_s ||= super end end end