module Authz::Scopables::Base
Any scopables created by the host application should extend this module. The module provides all the functionality that a scopable needs. @api private
Public Class Methods
When Scopables::Base
is extended, run within the context of the extending scopable
¶ ↑
# File lib/authz/scopables/base.rb, line 309 def self.extended(scopable) # self = Authz::Scopable::Base # scopable = scopable module that extended scopable.extend ActiveSupport::Concern self.register_scopable(scopable) # Any class that extends a Scopable gets these class methods # =================================================================== scopable.class_methods do # self = The class being scoped (the class that includes an scopable) # Defines a method that returns the name of the association to be used # for scoping. # For example, if Report includes ScopableByCity this will create a # scopable_by_city_association_name method. # # The method infers the association name to be used with the scopable. # If ambiguity is found, raises an Exception. # # This method should be overriden to manually set the association name. define_method scopable.association_method_name do association_name = (self.reflect_on_all_associations.map(&:name) & [scopable.singular_association_name.to_sym, scopable.plural_association_name.to_sym]) if association_name.size > 1 raise AmbiguousAssociationName, scoped_class: self.model_name.to_s, scopable: scopable, association_names: association_name end association_name.last end # Provides scoped classes with a convenient method to override the automatically inferred # association name for a given scopable. # # Usage: # include ScopableByCity # set_scopable_by_city_association_name :province define_method "set_#{scopable.association_method_name}" do |assoc_name| unless %w[Symbol String].include? assoc_name.class.name raise 'only strings or symbols are allowed' end define_singleton_method(scopable.association_method_name) { assoc_name.to_sym } end # Applies the scopable keyword on the class # @return a collection of the scoped class record after applying the scope define_method scopable.apply_scopable_method_name do |keyword, requester| keyword = scopable.normalize_if_special_keyword(keyword) if self.name == scopable.scoping_class_name # If the scoped class is the same scoping class # (e.g City and ScopableByCity) # Treatment for special keywords return self.all if keyword == :all scoped_ids = scopable.resolve_keyword!(keyword, requester) return self.where(id: scoped_ids) elsif (association_name = self.send(scopable.association_method_name)) # If the scoped class scoped by the scoping class # (e.g Report and ScopableByCity) Join through the association to query joined_collection = self.left_outer_joins(association_name) # Always left_outer_joins to account for records that are not # associated with the scoping class (e.g. reports with no city) # Report.left_outer_joins(:city) # Treatment for special keywords # TODO: the collection is forced to get joined to ensure structural # compatibility with ActiveRecord#or return joined_collection.all if keyword == :all scoped_ids = scopable.resolve_keyword!(keyword, requester) return joined_collection.merge(scopable.scoping_class.where(id: scoped_ids)) # Report.joins(:city).merge(City.where(id: [1,2,3])) else raise NoAssociationFound, scoped_class: self.model_name.to_s, scopable: scopable, scoping_class: scopable.scoping_class_name end end end end
Returns all the applicable scopable modules for the given collection_or_class
# File lib/authz/scopables/base.rb, line 43 def self.get_applicable_scopables collection_or_class get_scopables_modules.select do |scopable| scopable_by?(collection_or_class, scopable) end end
Returns all the applicable scopable modules for the given collection_or_class and raises an error if none are found
# File lib/authz/scopables/base.rb, line 52 def self.get_applicable_scopables! collection_or_class app_scopables = get_applicable_scopables(collection_or_class) return app_scopables if app_scopables.any? raise NoApplicableScopables, scoped_class: collection_or_class end
Returns an array of the scoping module instances
# File lib/authz/scopables/base.rb, line 22 def self.get_scopables_modules @@scopables end
Returns an array with the names of the modules in camelcase (string)
# File lib/authz/scopables/base.rb, line 17 def self.get_scopables_names @@scopables.map{ |s| s.name } end
# File lib/authz/scopables/base.rb, line 12 def self.register_scopable(scopable) @@scopables << scopable unless @@scopables.include?(scopable) end
Returns true if the given collection_or_class is scopable by the given scopable module
# File lib/authz/scopables/base.rb, line 37 def self.scopable_by? collection_or_class, scopable collection_or_class.respond_to?(scopable.association_method_name) end
Returns true if the given scopable name exists as a valid scopable @scopable_name: the string name of the scopable to
test
@return: true or false
# File lib/authz/scopables/base.rb, line 31 def self.scopable_exists?(scopable_name) get_scopables_names.include?(scopable_name.to_s) end
Returns an array with the special keywords
# File lib/authz/scopables/base.rb, line 59 def self.special_keywords # TODO: consider adding keyword none [:all] end
Public Instance Methods
Returns the mame of the method used to apply the scopable keyword on the scoped class
# File lib/authz/scopables/base.rb, line 189 def apply_scopable_method_name "apply_#{to_s.underscore}" end
Receives an instance of any class that is scopable by this scopable and returns an array of ids of the associated scoping instances.
For example:
-
Receives a report and returns an array with the
the id of the city associated with the report [32] or [] if not associated
-
Receives an announcement and returns an array with the
ids of the cites in which it is available [1,2,3] or [] if not associated
# File lib/authz/scopables/base.rb, line 270 def associated_scoping_instances_ids(instance_to_check) scoped_class = instance_to_check.class # When the instance is an instances of the Scoping Class # (e.g when we are checking the associated cities of a city) return [instance_to_check.id] if scoped_class == scoping_class assoc_method = scoped_class.send(association_method_name) instance_scope = instance_to_check.send(assoc_method) # instance_scope = report.city => a city instance / nil # instance_scope = announcement.cities => AR Relation of Cities / (may be empty) if instance_scope.class == scoping_class # When the instance is associated with ONE instance of the scoping class # (e.g report is associated with one city) instance_scope_ids = [instance_scope.id] elsif instance_scope.nil? # When the instance is associated with ONE instance of scoping class # but the association is empty (e.g. a record with no city) instance_scope_ids = [] elsif instance_scope.respond_to? 'pluck' # When the instance is associated with MANY instances of the scoping # class. Even if the association is empty # (e.g announcement is available in many cities) instance_scope_ids = instance_scope.pluck(:id) else raise MisconfiguredAssociation, scoped_class: scoped_class, scopable: self, association_method: assoc_method end instance_scope_ids end
Returns the name of the method used to get the name of the association for this scopable. Eg: “scopable_by_city_association_name”
# File lib/authz/scopables/base.rb, line 183 def association_method_name "scopable_by_#{scoping_class_name.underscore}_association_name" end
@return [Array<String>] available keywords for creating scoping rules @raise [NotImplementedError] when the scopable does not implement the method @api public
# File lib/authz/scopables/base.rb, line 408 def available_keywords raise NotImplementedError, "#{self}. All Scopables must implement a method that returns the available scoping keywords" end
Normalizes the keyword if it is a special keyword
# File lib/authz/scopables/base.rb, line 203 def normalize_if_special_keyword(keyword) norm = keyword.downcase.to_sym Authz::Scopables::Base.special_keywords.include?(norm) ? norm : keyword end
Symbol of a plural association following Rails' conventions
# File lib/authz/scopables/base.rb, line 176 def plural_association_name scoping_class.model_name.plural.to_sym end
@param keyword [String] the keyword that needs to be resolved @param requester [Models::Rolable] the user that is the bearer of the keyword @return [Array<Integers>] The ids that the given keywords resolve to @raise [NotImplementedError] when the scopable does not implement the method @api public
# File lib/authz/scopables/base.rb, line 419 def resolve_keyword(keyword, requester) msg = "#{self} must implement a method " \ ' that takes in a keyword and the requester' \ ' (e.g. the user) and returns an array of ids of ' \ "#{self.scoping_class_name} for that keyword" raise NotImplementedError, msg end
Resolution¶ ↑
Calls .resolve_keyword and ensures that the returned value is valid.
# File lib/authz/scopables/base.rb, line 211 def resolve_keyword!(keyword, requester) resolved_ids = resolve_keyword(keyword, requester) if resolved_ids.is_a? Array resolved_ids else raise UnresolvableKeyword, scopable: self, keyword: keyword, requester: requester end end
Returns the Active Record Class of the Model used to scope
# File lib/authz/scopables/base.rb, line 166 def scoping_class scoping_class_name.constantize end
Returns the string name of the class used to scope
# File lib/authz/scopables/base.rb, line 161 def scoping_class_name self.to_s.remove('ScopableBy') end
Symbol of a singular association following Rails'conventions
# File lib/authz/scopables/base.rb, line 171 def singular_association_name scoping_class.model_name.singular.to_sym end
Returns true if the given keyword is valid @param keyword: keyword being tested
# File lib/authz/scopables/base.rb, line 197 def valid_keyword?(keyword) available_keywords.include?(keyword) end
Returns true if the given instance_to_check is within the scoping privileges of the given keyword, optionally passing the requester to aide the resolution of the keyword.
# File lib/authz/scopables/base.rb, line 225 def within_scope_of_keyword?(instance_to_check, keyword, requester) keyword = normalize_if_special_keyword(keyword) # Shortcut treatment for special keywords return true if keyword == :all instance_scope_ids = associated_scoping_instances_ids(instance_to_check) role_scope_ids = resolve_keyword!(keyword, requester) # Resolution if instance_scope_ids.any? # When instance is associated to scoping class (report with city) # Resolve by intersection # TODO: if this becomes a problem, we could add # another parameter indicating the type of the match # e.g. match: :any, match: :all # "any" If announcement is available in 1,2,3 and I have 3 then I can see it # "all" If I am trying to create an announcement for 1,2, and I only have 1 then it should be denied (instance_scope_ids & role_scope_ids).any? else # When instance is not associated to scoping class # (e.g report with no city, announcement not available, # in any city, city that has not been persisted) # Fix singularity problem that allowed resolved keywords # that include nil to consider non persisted instances # of scoping classes (e.g. a city that has not been saved # yet) within scope. (ultimately allowing the creation # to out of scope) return false if instance_to_check.class == scoping_class role_scope_ids.include? nil end end