Module: Authz::Scopables::Base Private

Defined in:
lib/authz/scopables/base.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

Any scopables created by the host application should extend this module. The module provides all the functionality that a scopable needs.

Defined Under Namespace

Classes: AmbiguousAssociationName, MisconfiguredAssociation, NoApplicableScopables, NoAssociationFound, UnresolvableKeyword

Constant Summary collapse

@@scopables =

This classvariable is part of a private API. You should avoid using this classvariable if possible, as it may be removed or be changed in the future.

Scopable::Base tracks all available Scopables

[]

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.extended(scopable) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

When Scopables::Base is extended, run within the context of the extending scopable



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# 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) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns all the applicable scopable modules for the given collection_or_class



43
44
45
46
47
# 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) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns all the applicable scopable modules for the given collection_or_class and raises an error if none are found



52
53
54
55
56
# 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_modulesObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns an array of the scoping module instances



22
23
24
# File 'lib/authz/scopables/base.rb', line 22

def self.get_scopables_modules
  @@scopables
end

.get_scopables_namesObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns an array with the names of the modules in camelcase (string)



17
18
19
# File 'lib/authz/scopables/base.rb', line 17

def self.get_scopables_names
  @@scopables.map{ |s| s.name }
end

.register_scopable(scopable) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Contains a handle to each scopable



12
13
14
# 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) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns true if the given collection_or_class is scopable by the given scopable module

Returns:

  • (Boolean)


37
38
39
# 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) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

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

Returns:

  • (Boolean)


31
32
33
# File 'lib/authz/scopables/base.rb', line 31

def self.scopable_exists?(scopable_name)
  get_scopables_names.include?(scopable_name.to_s)
end

.special_keywordsObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns an array with the special keywords



59
60
61
62
# File 'lib/authz/scopables/base.rb', line 59

def self.special_keywords
  # TODO: consider adding keyword none
  [:all]
end

Instance Method Details

#apply_scopable_method_nameObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the mame of the method used to apply the scopable keyword on the scoped class



189
190
191
# 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) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

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



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# 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_nameObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the name of the method used to get the name of the association for this scopable. Eg: “scopable_by_city_association_name”



183
184
185
# File 'lib/authz/scopables/base.rb', line 183

def association_method_name
  "scopable_by_#{scoping_class_name.underscore}_association_name"
end

#available_keywordsArray<String>

Returns available keywords for creating scoping rules.

Returns:

  • (Array<String>)

    available keywords for creating scoping rules

Raises:

  • (NotImplementedError)

    when the scopable does not implement the method



408
409
410
411
412
# 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) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Normalizes the keyword if it is a special keyword



203
204
205
206
# 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_nameObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Symbol of a plural association following Rails’ conventions



176
177
178
# File 'lib/authz/scopables/base.rb', line 176

def plural_association_name
  scoping_class.model_name.plural.to_sym
end

#resolve_keyword(keyword, requester) ⇒ Array<Integers>

Returns The ids that the given keywords resolve to.

Parameters:

  • keyword (String)

    the keyword that needs to be resolved

  • requester (Models::Rolable)

    the user that is the bearer of the keyword

Returns:

  • (Array<Integers>)

    The ids that the given keywords resolve to

Raises:

  • (NotImplementedError)

    when the scopable does not implement the method



419
420
421
422
423
424
425
# 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) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resolution

Calls .resolve_keyword and ensures that the returned value is valid.



211
212
213
214
215
216
217
218
219
220
# 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_classObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the Active Record Class of the Model used to scope



166
167
168
# File 'lib/authz/scopables/base.rb', line 166

def scoping_class
  scoping_class_name.constantize
end

#scoping_class_nameObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the string name of the class used to scope



161
162
163
# File 'lib/authz/scopables/base.rb', line 161

def scoping_class_name
  self.to_s.remove('ScopableBy')
end

#singular_association_nameObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Symbol of a singular association following Rails’conventions



171
172
173
# File 'lib/authz/scopables/base.rb', line 171

def singular_association_name
  scoping_class.model_name.singular.to_sym
end

#valid_keyword?(keyword) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns true if the given keyword is valid

Parameters:

  • keyword:

    keyword being tested

Returns:

  • (Boolean)


197
198
199
# 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) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

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.

Returns:

  • (Boolean)


225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# 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