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

extended(scopable) click to toggle source

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
get_applicable_scopables(collection_or_class) click to toggle source

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
get_applicable_scopables!(collection_or_class) click to toggle source

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
get_scopables_modules() click to toggle source

Returns an array of the scoping module instances

# File lib/authz/scopables/base.rb, line 22
def self.get_scopables_modules
  @@scopables
end
get_scopables_names() click to toggle source

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
register_scopable(scopable) click to toggle source
# File lib/authz/scopables/base.rb, line 12
def self.register_scopable(scopable)
  @@scopables << scopable unless @@scopables.include?(scopable)
end
scopable_by?(collection_or_class, scopable) click to toggle source

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
scopable_exists?(scopable_name) click to toggle source

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
special_keywords() click to toggle source

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

apply_scopable_method_name() click to toggle source

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
associated_scoping_instances_ids(instance_to_check) click to toggle source

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:

  1. Receives a report and returns an array with the

the id of the city associated with the report [32] or [] if not associated

  1. 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
association_method_name() click to toggle source

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
available_keywords() click to toggle source

@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
normalize_if_special_keyword(keyword) click to toggle source

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
plural_association_name() click to toggle source

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
resolve_keyword(keyword, requester) click to toggle source

@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
resolve_keyword!(keyword, requester) click to toggle source

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
scoping_class() click to toggle source

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
scoping_class_name() click to toggle source

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
singular_association_name() click to toggle source

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
valid_keyword?(keyword) click to toggle source

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
within_scope_of_keyword?(instance_to_check, keyword, requester) click to toggle source

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