module JSONAPI module Authorization # An authorizer is a class responsible for linking JSONAPI operations to # your choice of authorization mechanism. # # This class uses Pundit for authorization. You can use your own authorizer # class instead if you have different needs. See the README.md for # configuration information. # # Fetching records is the concern of +PunditScopedResource+ which in turn # affects which records end up being passed here. class DefaultPunditAuthorizer attr_reader :user # Creates a new DefaultPunditAuthorizer instance # # ==== Parameters # # * +context+ - The context passed down from the controller layer def initialize(context:) @user = JSONAPI::Authorization.configuration.user_context(context) end # GET /resources # # ==== Parameters # # * +source_class+ - The source class (e.g. +Article+ for +ArticleResource+) def find(source_class:) ::Pundit.authorize(user, source_class, 'index?') end # GET /resources/:id # # ==== Parameters # # * +source_record+ - The record to show def show(source_record:) ::Pundit.authorize(user, source_record, 'show?') end # GET /resources/:id/relationships/other-resources # GET /resources/:id/relationships/another-resource # # A query for a +has_one+ or a +has_many+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is queried # * +related_record+ - The associated +has_one+ record to show or +nil+ # if the associated record was not found. For a +has_many+ association, # this will always be +nil+ def show_relationship(source_record:, related_record:) ::Pundit.authorize(user, source_record, 'show?') ::Pundit.authorize(user, related_record, 'show?') unless related_record.nil? end # GET /resources/:id/another-resource # # A query for a record through a +has_one+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is queried # * +related_record+ - The associated record to show or +nil+ if the # associated record was not found def show_related_resource(source_record:, related_record:) ::Pundit.authorize(user, source_record, 'show?') ::Pundit.authorize(user, related_record, 'show?') unless related_record.nil? end # GET /resources/:id/other-resources # # A query for records through a +has_many+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is queried # * +related_record_class+ - The associated record class to show def show_related_resources(source_record:, related_record_class:) ::Pundit.authorize(user, source_record, 'show?') ::Pundit.authorize(user, related_record_class, 'index?') end # PATCH /resources/:id # # ==== Parameters # # * +source_record+ - The record to be modified # * +related_records_with_context+ - A hash with the association type, # the relationship name, an Array of new related records. def replace_fields(source_record:, related_records_with_context:) ::Pundit.authorize(user, source_record, 'update?') authorize_related_records( source_record: source_record, related_records_with_context: related_records_with_context ) end # POST /resources # # ==== Parameters # # * +source_class+ - The class of the record to be created # * +related_records_with_context+ - A has with the association type, # the relationship name, and an Array of new related records. def create_resource(source_class:, related_records_with_context:) ::Pundit.authorize(user, source_class, 'create?') related_records_with_context.each do |data| relation_name = data[:relation_name] records = data[:records] relationship_method = "create_with_#{relation_name}?" policy = ::Pundit.policy(user, source_class) if policy.respond_to?(relationship_method) unless policy.public_send(relationship_method, records) raise ::Pundit::NotAuthorizedError, query: relationship_method, record: source_class, policy: policy end else Array(records).each do |record| ::Pundit.authorize(user, record, 'update?') end end end end # DELETE /resources/:id # # ==== Parameters # # * +source_record+ - The record to be removed def remove_resource(source_record:) ::Pundit.authorize(user, source_record, 'destroy?') end # PATCH /resources/:id/relationships/another-resource # # A replace request for a +has_one+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is modified # * +new_related_record+ - The new record replacing the old record # * +relationship_type+ - The relationship type def replace_to_one_relationship(source_record:, new_related_record:, relationship_type:) relationship_method = "replace_#{relationship_type}?" authorize_relationship_operation( source_record: source_record, relationship_method: relationship_method, related_record_or_records: new_related_record ) end # POST /resources/:id/relationships/other-resources # # A request for adding to a +has_many+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is modified # * +new_related_records+ - The new records to be added to the association # * +relationship_type+ - The relationship type def create_to_many_relationship(source_record:, new_related_records:, relationship_type:) relationship_method = "add_to_#{relationship_type}?" authorize_relationship_operation( source_record: source_record, relationship_method: relationship_method, related_record_or_records: new_related_records ) end # PATCH /resources/:id/relationships/other-resources # # A replace request for a +has_many+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is modified # * +new_related_records+ - The new records replacing the entire +has_many+ # association # * +relationship_type+ - The relationship type def replace_to_many_relationship(source_record:, new_related_records:, relationship_type:) relationship_method = "replace_#{relationship_type}?" authorize_relationship_operation( source_record: source_record, relationship_method: relationship_method, related_record_or_records: new_related_records ) end # DELETE /resources/:id/relationships/other-resources # # A request to disassociate elements of a +has_many+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is modified # * +related_records+ - The records which will be disassociated from +source_record+ # * +relationship_type+ - The relationship type def remove_to_many_relationship(source_record:, related_records:, relationship_type:) relationship_method = "remove_from_#{relationship_type}?" authorize_relationship_operation( source_record: source_record, relationship_method: relationship_method, related_record_or_records: related_records ) end # DELETE /resources/:id/relationships/another-resource # # A request to disassociate a +has_one+ association # # ==== Parameters # # * +source_record+ - The record whose relationship is modified # * +relationship_type+ - The relationship type def remove_to_one_relationship(source_record:, relationship_type:) relationship_method = "remove_#{relationship_type}?" authorize_relationship_operation( source_record: source_record, relationship_method: relationship_method ) end # Any request including ?include=other-resources # # This will be called for each has_many relationship if the include goes # deeper than one level until some authorization fails or the include # directive has been travelled completely. # # We can't pass all the records of a +has_many+ association here due to # performance reasons, so the class is passed instead. # # ==== Parameters # # * +source_record+ — The source relationship record, e.g. an Article in # article.comments check # * +record_class+ - The underlying record class for the relationships # resource. # rubocop:disable Lint/UnusedMethodArgument def include_has_many_resource(source_record:, record_class:) ::Pundit.authorize(user, record_class, 'index?') end # rubocop:enable Lint/UnusedMethodArgument # Any request including ?include=another-resource # # This will be called for each has_one relationship if the include goes # deeper than one level until some authorization fails or the include # directive has been travelled completely. # # ==== Parameters # # * +source_record+ — The source relationship record, e.g. an Article in # article.author check # * +related_record+ - The associated record to return # rubocop:disable Lint/UnusedMethodArgument def include_has_one_resource(source_record:, related_record:) ::Pundit.authorize(user, related_record, 'show?') end # rubocop:enable Lint/UnusedMethodArgument private def authorize_relationship_operation( source_record:, relationship_method:, related_record_or_records: nil ) policy = ::Pundit.policy(user, source_record) if policy.respond_to?(relationship_method) args = [relationship_method, related_record_or_records].reject(&:nil?) unless policy.public_send(*args) raise ::Pundit::NotAuthorizedError, query: relationship_method, record: source_record, policy: policy end else ::Pundit.authorize(user, source_record, 'update?') if related_record_or_records Array(related_record_or_records).each do |related_record| ::Pundit.authorize(user, related_record, 'update?') end end end end def authorize_related_records(source_record:, related_records_with_context:) related_records_with_context.each do |data| relation_type = data[:relation_type] relation_name = data[:relation_name] records = data[:records] case relation_type when :to_many replace_to_many_relationship( source_record: source_record, new_related_records: records, relationship_type: relation_name ) when :to_one if records.nil? remove_to_one_relationship( source_record: source_record, relationship_type: relation_name ) else replace_to_one_relationship( source_record: source_record, new_related_record: records, relationship_type: relation_name ) end end end end end end end